Skip to main content

bynk_emit/
project.rs

1//! Multi-file project compilation (v0.3 §3.2 and §3.3, v0.4 §3.5).
2//!
3//! A "project" is a directory tree of `.bynk` source files. The dotted name
4//! of a commons or context (e.g., `bynk.time`, `commerce.orders`) maps to a
5//! path under the project root — either a single file (`bynk/time.bynk`) or
6//! a directory of files all sharing the same header (`bynk/time/*.bynk`).
7//!
8//! v0.4: each file is one of two kinds — commons or context. Both kinds share
9//! the same multi-file directory machinery; they differ in body content
10//! (contexts have `consumes`/`exports`, types are nominally per-context), in
11//! visibility (contexts export only the types listed), and in TypeScript
12//! emission (contexts re-brand types from used commons).
13//!
14//! Compilation proceeds in two passes:
15//!   1. **Discover and parse** every `.bynk` file. Group by qualified name
16//!      and kind. Build a global symbol table where each unit contributes
17//!      its declarations.
18//!   2. **Resolve, type-check, and emit** each unit with full visibility of
19//!      the units it transitively `uses` or `consumes`. Two passes keep
20//!      `uses` cycles trivial — there is no order-of-evaluation, only
21//!      declarative mixin.
22
23use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
24use std::path::{Component, Path, PathBuf};
25use std::sync::Arc;
26
27use crate::emitter;
28use bynk_check::actors::ActorDecl;
29use bynk_check::check_pipeline::{self, prepare_unit_check_ctx};
30use bynk_check::checker;
31use bynk_check::checker::{ExprId, TyId, Types};
32use bynk_check::expr_types::ExprTypeSink;
33use bynk_check::firstparty::{self, Platform};
34use bynk_check::hints::HintSink;
35use bynk_check::index::RefSink;
36use bynk_check::locals::LocalsSink;
37use bynk_check::project_model::{
38    self, AdapterBinding, FnDecl, TypeDecl, UnitInfo, Visibility, handler_cross_caps,
39    resolve_consume_prefix,
40};
41use bynk_check::requirements::RequirementSink;
42use bynk_check::resolver::{self, MethodTable as ResolverMethodTable, ResolvedCommons};
43use bynk_ir::CapRefIr;
44use bynk_ir::EventSubscriberShape;
45use bynk_ir::FnSig;
46use bynk_lower::{
47    lower_attached_fn_sig_ir_from_types, lower_handler_given_ir, lower_provider_given_ir,
48};
49use bynk_syntax::error::CompileError;
50use bynk_syntax::lexer;
51use bynk_syntax::parser;
52use bynk_ts::{TsBindingName, TsDecl, TsExpr, TsLit, TsParam, TsProgram, TsStmt, TsType};
53
54// P4.0 (#1113): discovery, the unit graph, path resolution, and cross-unit
55// consistency checks moved to `bynk-project` — this crate is now a
56// dependent, not an owner, of that code (`design/tracks/project-model.md`
57// §6 row P4.0). P4.1 (#1115): `symbols` and the per-file `context_checks`
58// subset of `validate` moved to `bynk-check` for the same reason — this
59// crate reaches them as `bynk_check::symbols`/`bynk_check::context_checks`
60// now. P5.0-P5.3 (`design/tracks/semantics-in-the-checker.md` §6) emptied
61// `validate` out the same way, project-wide check by project-wide check,
62// including the reconciliation half of `schema_registry` (now
63// `bynk_check::schema_registry`). P5.4 moved `tests_emit`'s checking half
64// (target/participant resolution, `stub` resolution, case/property body
65// type-checking) to `bynk_check::test_suites` the same way — `tests_emit`
66// stays here as a caller of it, holding only TypeScript emission plus the
67// two functions' unchanged public shape. P5.5 (§5, §6): `validate` reached
68// empty (its last occupant, `check_platform_lock`, left at P5.3) and its
69// remaining diagnostic-emitting sites — the `bynk.secrets.computed_name`
70// warning and the `bynk.project.schema_registry_corrupt` construction, both
71// outside the seven-category accounting — relocated too (§3.2's "eighth
72// site" and §9's open risk respectively); `validate.rs` and its module
73// declaration are deleted, this track's own completion criterion (§5).
74// Pipeline-driving types (`diagnostics`'s `Mode`/`ErrorSink`/
75// `ProjectAnalysis`/`ProjectFailure`) stay here too.
76mod diagnostics;
77mod schema_registry;
78mod tests_emit;
79mod worker_bindings;
80
81use bynk_check::symbols::*;
82use bynk_project::discovery::*;
83use bynk_project::paths::*;
84use diagnostics::*;
85use tests_emit::*;
86
87// External facade: items referenced as `crate::project::X` from outside this
88// module (emitter, main, lib) must stay reachable at that path.
89pub use bynk_check::project_model::BuildTarget;
90pub use bynk_check::schema_registry::{AgentVersions, MigrationStep};
91pub use bynk_check::symbols::{FileDeclIndex, UnitTable};
92// #1883, slice 1b: the `assets/` folder helpers, for the driver and testkit.
93pub use bynk_project::assets;
94pub use bynk_project::{
95    AttributedError, LabelFile, ProjectPaths, ProjectPathsError, Roots, SchemaLock, TestTarget,
96    UnitKind, WorkersConfig, Workspace, check_manifest, check_manifest_str, dependency_trees,
97    discover_project_files, is_workspace_root, load_workspace, try_read_project_paths,
98    try_read_project_paths_with, try_read_test_targets, try_read_workers_config,
99    try_read_workers_config_with, worker_dir_name, worker_handlers_output_path,
100    worker_handlers_source_path,
101};
102pub use diagnostics::{ContextBoundaryInfo, ContextSequenceInfo, ProjectAnalysis, ProjectFailure};
103
104/// A project's output, as a keyed set of typed documents (R7.8, #1309) —
105/// replaces the old `ProjectOutput.files: Vec<CompiledFile>` outright, not
106/// alongside it ("Done when": `CompiledFile`/`ProjectOutput::files` are
107/// gone, not aliased). Keyed by output path (project-root-relative), so the
108/// sibling-path lookup `bynk-driver::output` needs for `.map`/
109/// `.bynkdbg.json` files is a direct map operation instead of a linear scan,
110/// and so a `wrangler.toml` document carries its real [`crate::emitter::
111/// toml_doc::TomlDocument`] all the way to the write boundary instead of
112/// being stringified at construction (Decision E).
113pub struct Artefacts {
114    pub docs: BTreeMap<PathBuf, Document>,
115    /// #1883, slice 0 (the binary-safe emission path the 2026-10-10 comment
116    /// names as a prerequisite): static-asset files, as raw bytes, keyed the
117    /// same way as `docs` (output-root-relative). Every key lies under a
118    /// Worker's [`WORKER_ASSETS_DIR`] ([`is_worker_asset_path`]).
119    ///
120    /// A sibling map rather than a `Document::Bytes` variant: every
121    /// `Document` has a text rendering ([`Document::text`], which golden
122    /// fixtures, `bynk-strip` and `bynk-wasm` all call), and an asset such as
123    /// a PNG has none. Keeping bytes out of `Document` means no caller of
124    /// `text()` has to decide what a binary file's text is. `bynk-strip`
125    /// passes this map through untouched; `bynk-driver::output::write_output`
126    /// writes and reconciles it alongside `docs`.
127    ///
128    /// Filled by a Workers build (slice 1b, `build_output`) with each Worker's
129    /// files and `_headers`; always empty for a Bundle build.
130    pub assets: BTreeMap<PathBuf, Vec<u8>>,
131    /// #2321: the WebAssembly modules adapter bindings include
132    /// (`binding "…" includes ["./x.wasm"]`), as raw bytes keyed by
133    /// output-root-relative path, beside the binding that loads them. Module
134    /// files are code, not assets: never served, and not under a Worker's
135    /// assets directory. Each has a generated loader shim at
136    /// [`wasm_shim_path`] in `docs` (the Workers form); a host that runs the
137    /// output on Node writes [`node_wasm_shim`] over it.
138    pub modules: BTreeMap<PathBuf, Vec<u8>>,
139}
140
141/// #2321: the generated loader beside an included module, whose default export
142/// is the compiled `WebAssembly.Module`. `adapters/x.wasm` → `adapters/x.wasm.ts`.
143pub fn wasm_shim_path(module: &Path) -> PathBuf {
144    let mut name = module.as_os_str().to_os_string();
145    name.push(".ts");
146    PathBuf::from(name)
147}
148
149/// The Workers form of the loader: Wrangler compiles `./x.wasm` to a
150/// `WebAssembly.Module` at deploy (`CompiledWasm` rule in `wrangler.toml`).
151fn workers_wasm_shim(module: &Path) -> String {
152    let file = module
153        .file_name()
154        .map(|n| n.to_string_lossy().into_owned())
155        .unwrap_or_default();
156    format!(
157        "// Generated by bynkc — do not edit by hand.\nimport module from \"./{file}\";\n\nexport default module;\n"
158    )
159}
160
161/// The Node form of the loader (#2321, decision B): the module's bytes inlined
162/// and compiled at load, so it needs no file beside the compiled output (`tsc`
163/// copies only TypeScript and JSON) and no Node typings. `bynk test` writes
164/// this over the Workers form at [`wasm_shim_path`].
165pub fn node_wasm_shim(bytes: &[u8]) -> String {
166    format!(
167        "// Generated by bynkc — do not edit by hand.\nconst bytes = Uint8Array.from(atob(\"{}\"), (c) => c.charCodeAt(0));\n\nexport default await WebAssembly.compile(bytes);\n",
168        base64_encode(bytes)
169    )
170}
171
172fn base64_encode(bytes: &[u8]) -> String {
173    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
174    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
175    for chunk in bytes.chunks(3) {
176        let n = (u32::from(chunk[0]) << 16)
177            | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
178            | u32::from(*chunk.get(2).unwrap_or(&0));
179        out.push(TABLE[(n >> 18) as usize & 63] as char);
180        out.push(TABLE[(n >> 12) as usize & 63] as char);
181        out.push(if chunk.len() > 1 {
182            TABLE[(n >> 6) as usize & 63] as char
183        } else {
184            '='
185        });
186        out.push(if chunk.len() > 2 {
187            TABLE[n as usize & 63] as char
188        } else {
189            '='
190        });
191    }
192    out
193}
194
195/// #2321: whether `ctx`'s `consumes` closure (through adapter-to-adapter
196/// `consumes` too) reaches an adapter whose binding includes a module.
197fn worker_reaches_wasm_module(
198    ctx: &str,
199    unit_consumes: &HashMap<String, Vec<String>>,
200    kinds: &BTreeMap<String, UnitKind>,
201    adapter_bindings: &HashMap<String, AdapterBinding>,
202) -> bool {
203    let mut seen: HashSet<&str> = HashSet::new();
204    let mut stack: Vec<&str> = vec![ctx];
205    while let Some(unit) = stack.pop() {
206        if !seen.insert(unit) {
207            continue;
208        }
209        if adapter_bindings
210            .get(unit)
211            .is_some_and(|b| !b.includes.is_empty())
212        {
213            return true;
214        }
215        // Another context is a Service Binding away, not in this Worker.
216        if unit != ctx && kinds.get(unit) == Some(&UnitKind::Context) {
217            continue;
218        }
219        stack.extend(
220            unit_consumes
221                .get(unit)
222                .into_iter()
223                .flatten()
224                .map(String::as_str),
225        );
226    }
227    false
228}
229
230/// Ambient type for the Workers loader's `import module from "./x.wasm"`.
231const WASM_MODULES_DTS: &str = "// Generated by bynkc — do not edit by hand.\ndeclare module \"*.wasm\" {\n  const module: WebAssembly.Module;\n  export default module;\n}\n";
232
233/// #1883, slice 0: the name of the directory, inside a Worker's own
234/// `workers/<dashes>/` output directory, that holds its static assets — the
235/// directory the generated `wrangler.toml`'s `[assets] directory` names
236/// (`emitter::wrangler::assets_block`). Separate from the Worker's own output so the compiled Worker and
237/// its source maps are never served as public assets.
238///
239/// The compiler owns everything under it: `bynk-driver::output::write_output`
240/// prunes any file there that the current build did not produce, whatever
241/// its extension (an asset keeps its source's extension, and the platform's
242/// `_headers` file has none). Shared by the producer and the pruner through
243/// [`worker_assets_dir`] / [`is_worker_asset_path`] so the two can't drift.
244pub const WORKER_ASSETS_DIR: &str = "assets";
245
246/// #1883, slice 0: `workers/<dashes>/<WORKER_ASSETS_DIR>`, the output-root-
247/// relative directory holding one Worker's static assets.
248pub fn worker_assets_dir(dashes: &str) -> PathBuf {
249    PathBuf::from("workers")
250        .join(dashes)
251        .join(WORKER_ASSETS_DIR)
252}
253
254/// #1883, slice 0: whether `rel` (output-root-relative) names a file inside
255/// some Worker's assets directory — `workers/<dashes>/<WORKER_ASSETS_DIR>/…`,
256/// at any depth. The ownership rule `bynk-driver::output`'s reconciliation
257/// applies regardless of extension.
258///
259/// Every component after the assets directory must be a plain name
260/// (`Component::Normal`), not just present. Review of #1902: `components()`
261/// keeps `..`, so checking only that the tail was non-empty accepted
262/// `workers/x/assets/../../../../etc/x`, which the driver's `dir.join` would
263/// then write outside the output root, where reconciliation can never see
264/// it. Slice 1 derives these paths from source, so this is a security rule,
265/// not tidiness.
266pub fn is_worker_asset_path(rel: &Path) -> bool {
267    use std::path::Component;
268    let mut parts = rel.components();
269    let mut tail_len = 0usize;
270    matches!(parts.next(), Some(Component::Normal(w)) if w == "workers")
271        && matches!(parts.next(), Some(Component::Normal(_)))
272        && matches!(parts.next(), Some(Component::Normal(a)) if a == WORKER_ASSETS_DIR)
273        && parts.all(|c| {
274            tail_len += 1;
275            matches!(c, Component::Normal(_))
276        })
277        && tail_len > 0
278}
279
280/// One typed output document. No `String` at construction for TypeScript or
281/// TOML content (R7.8) — `Json`/`Js`/`SourceMap`/`DebugSidecar` stay opaque
282/// payloads because `bynk-emit` never re-parses them itself, not because
283/// they're an escape hatch from the rule.
284pub enum Document {
285    /// A generated TypeScript module. Every `bynk-emit` construction site
286    /// builds this today by wrapping its still-`String`-producing content in
287    /// one `TsStmt::verbatim(VerbatimOrigin::NotYetConverted, ..)` — see that
288    /// variant's own doc comment for why each site does this literally,
289    /// rather than through one shared helper.
290    Ts(bynk_ts::TsProgram),
291    /// `wrangler.toml`, still as the real tree `emit_wrangler_toml` built —
292    /// printed only at the `bynk-driver` write boundary.
293    Toml(crate::emitter::toml_doc::TomlDocument),
294    /// Generated JSON with no tree this compiler tracks yet (`package.json`,
295    /// `tsconfig.json`, the contracts/secrets manifests).
296    Json(String),
297    /// Type-stripped JavaScript (`bynk-strip::strip_project_to_js`'s own
298    /// output) — never produced by `bynk-emit` itself, which only ever
299    /// builds `Ts`; kept distinct from `Ts` so a stripped artefact never
300    /// round-trips back through the TypeScript printer/lint.
301    Js(String),
302    /// A source-map v3 document, split out of its originating file's own
303    /// staged `source_map` at the sibling path `write_output` names it
304    /// (`<file>.map`).
305    SourceMap(String),
306    /// The handler-label debug sidecar, split out the same way at
307    /// `<file>.bynkdbg.json`.
308    DebugSidecar(String),
309}
310
311impl Document {
312    /// This document's own text, whichever kind it is — for read-side
313    /// callers that only need bytes (golden fixtures, `bynk-strip`'s own
314    /// need to feed `Ts` content through `strip_types`, `bynk-wasm`'s
315    /// JS-facing API, in-process test helpers). `Ts`/`Toml` print through
316    /// the same printer `bynk-driver::output::write_document` writes from
317    /// (R7.3/R7.6) — this is a rendering, not a second construction path;
318    /// nothing here builds a `Document` from a `String`.
319    pub fn text(&self) -> String {
320        match self {
321            Document::Ts(program) => bynk_ts::print(program, "", "", "").text,
322            Document::Toml(doc) => crate::emitter::print_toml_document(doc),
323            Document::Json(s)
324            | Document::Js(s)
325            | Document::SourceMap(s)
326            | Document::DebugSidecar(s) => s.clone(),
327        }
328    }
329}
330
331/// One generated document, staged during `build_output`/`emit_unit` before
332/// the final split into [`Artefacts`] — see `build_output`'s own tail.
333struct StagedFile {
334    output_path: PathBuf,
335    source_map: Option<String>,
336    debug_metadata: Option<String>,
337    document: Document,
338}
339
340/// Result of compiling a project.
341pub struct ProjectOutput {
342    /// P7.6 (#1309): every generated document, typed (R7.8).
343    /// `bynk-driver::output::write_output` writes from this.
344    pub artefacts: Artefacts,
345    /// v0.89 (ADR 0117): non-failing warnings emitted on a successful build —
346    /// surfaced (the CLI prints them, the LSP shows them) but not gating.
347    pub warnings: Vec<AttributedError>,
348    /// Per-file source snapshots, keyed the same way `ProjectFailure::snapshots`
349    /// is — lets a warning render with real file/line/col context (ariadne or
350    /// `path:line:col:`) instead of the position-free `warning[category]: …`
351    /// fallback a successful build previously had no way to avoid.
352    pub snapshots: Vec<(PathBuf, String)>,
353    /// #1772: the project or tree root the build was given, as the caller
354    /// spelled it. `snapshots` and each error's `source_path` are keyed by
355    /// identity path, relative to this root; a renderer shows
356    /// `display_root.join(identity)`, the path as typed from the working
357    /// directory. Empty for an in-memory build.
358    pub display_root: PathBuf,
359    /// v0.67: the test manifest — every discovered suite and case, retained at
360    /// emit time so `bynkc test --no-run --format json` can render a discovery
361    /// document without running the suite. Built from the same names + spans the
362    /// runner would emit at `suite-begin`/`case`, so a discovery document
363    /// reconciles cleanly against a later run's document (same suite name/kind,
364    /// same case names). Ordered to match the runner (`emit_test_main`).
365    pub discovered: Vec<DiscoveredSuite>,
366    /// #1078: the reconciled `bynk.schema.lock` content, `Some` whenever
367    /// `CompileOptions::schema_registry` was `SchemaLock::On` for this build
368    /// — `bynk-emit` computes it but never writes it; a building caller
369    /// (`bynkc compile`, `bynk dev`/`deploy`) persists it atomically, exactly
370    /// the discipline `schema_registry.rs` used to implement itself. `None`
371    /// when the registry was off, regardless of whether the content would
372    /// have changed — the unchanged-content no-op lives in the writer, not
373    /// here, so a caller that reconciles the same shape twice in a row still
374    /// gets `Some` both times.
375    pub schema_lock: Option<String>,
376}
377
378/// v0.67: a discovered test suite — one `test <target>` group (unit) or
379/// `test integration "<suite>"` (integration). `name` + `kind` mirror exactly
380/// what the NDJSON runner emits at `suite-begin` (kind `"unit"` carries the
381/// joined target name; `"integration"` carries the bare suite name), so the
382/// editor reconciles discovery and run documents to the same tree items.
383#[derive(Debug, Clone, PartialEq)]
384pub struct DiscoveredSuite {
385    pub name: String,
386    pub kind: &'static str,
387    pub cases: Vec<DiscoveredCase>,
388    /// #2138: the cases a remote build skipped (`--skip-unsupported`), and
389    /// why. Empty for every other build.
390    pub skipped: Vec<RemoteSkip>,
391    /// #2138: under `--against dev`, the distinct `env` maps this suite's
392    /// cases run with ([`RemoteTests::dev`]). Empty for every other build.
393    pub envs: Vec<BTreeMap<String, String>>,
394}
395
396/// One discovered `test "<name>"` case. `location` points at the case-name
397/// literal (a run *failure* instead points at the failing `assert`), giving the
398/// editor click-through to the declaration before any run.
399#[derive(Debug, Clone, PartialEq)]
400pub struct DiscoveredCase {
401    pub name: String,
402    pub location: Option<TestLocation>,
403    /// The case's effective tier (`"unit"`, `"integration"` or `"system"`: its own
404    /// `as <tier>`, else the suite default, else `unit`), when it has one. A
405    /// property or contract attack has none.
406    pub tier: Option<&'static str>,
407    /// `"property"` or `"history"` for a generative case (a `property`, a history
408    /// property or a contract attack), whose draws depend on the run's seed and on
409    /// its position among the module's generative cases. `None` for a `case`.
410    pub generative: Option<&'static str>,
411}
412
413/// A project-root-relative `path:line:col` source location, structured. Line and
414/// col are 1-indexed (the [`bynk_syntax::span::line_col`] convention).
415#[derive(Debug, Clone, PartialEq)]
416pub struct TestLocation {
417    pub path: String,
418    pub line: u32,
419    pub col: u32,
420}
421
422// P4.1 (#1115): `AdapterBinding` and `BuildTarget` relocated to
423// `bynk-check::project_model` alongside `phase_group`, which constructs
424// them — see that module's own doc comment. `BuildTarget` is re-exported
425// below at its old `crate::project::BuildTarget` path (part of this crate's
426// public surface); `AdapterBinding` was never public here, so a plain `use`
427// (above) is enough.
428
429/// The extension emitted import specifiers use (`import … from "./x.<ext>"`).
430///
431/// `Js` is the default and the only shape for normal builds: Node's ESM
432/// resolution and `tsc` require `.js` specifiers even though the sources are `.ts`. `Ts` is
433/// the **debug build** (slice 2, ADR 0104): `bynkc test --inspect` runs the
434/// emitted `.ts` directly under Node's line-preserving strip-only type-stripping,
435/// where slice 1's source maps apply unchanged — but Node will not resolve a `.js`
436/// specifier to the `.ts` on disk, so the debug build emits `.ts` specifiers.
437#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
438pub enum ImportExt {
439    #[default]
440    Js,
441    Ts,
442}
443
444impl ImportExt {
445    /// The bare extension string (`"js"` / `"ts"`), for `Path::with_extension`
446    /// and specifier formatting.
447    pub fn as_str(self) -> &'static str {
448        match self {
449            ImportExt::Js => "js",
450            ImportExt::Ts => "ts",
451        }
452    }
453}
454
455/// Options for [`compile_project`]. Construct with [`CompileOptions::single`] or
456/// [`CompileOptions::split`], then chain `.target(…)` / `.platform(…)` /
457/// `.import_ext(…)` to override the bundle/default-platform/`.js` defaults.
458#[derive(Clone)]
459pub struct CompileOptions {
460    pub target: BuildTarget,
461    pub platform: Platform,
462    pub roots: Roots,
463    /// The import-specifier extension (slice 2). `Js` (default) for normal builds;
464    /// `Ts` for the `bynkc test --inspect` debug build.
465    pub import_ext: ImportExt,
466    /// v0.115 (testing track slice 3, DECISION J): the build profile for function
467    /// contracts. `true` (dev/test) emits the call-site guard around a contracted
468    /// `fn`; `false` (release/deploy) strips it entirely for zero runtime cost.
469    /// `bynkc test` and `--inspect` set it on; `bynkc compile` leaves it off.
470    pub contracts: bool,
471    /// #1821 (ADR 0147 D3): whether `suite`s are type-checked and emitted (the
472    /// `tests/` modules and `tests/main.ts`). On by default; `bynkc compile`
473    /// and the driver's dev/deploy build turn it off, so a suite never reaches
474    /// the deployable. `bynkc test` leaves it on. Off strips suites after
475    /// parsing: a file that does not parse still fails the build, since it
476    /// cannot be told apart from production source.
477    pub tests: bool,
478    /// #57 (testing track): when `Some`, every file `roots` would otherwise
479    /// discover on disk is instead read from here — keyed the same way
480    /// `discovery::read_source`'s overlay is (a canonicalised absolute path,
481    /// falling back to the literal path when the file has no on-disk
482    /// counterpart to canonicalise). Filesystem discovery is skipped entirely;
483    /// `roots` still supplies `src_root`/`tests_root` and their prefixes, so a
484    /// `Roots::Split` project can drive both trees in-memory.
485    ///
486    /// #1077/#1081: `bynk-driver`'s `project_options`/`try_project_options`
487    /// *do* set this now — the real CLI entry points walk and read every
488    /// project file themselves and hand the result here, so `bynk-emit`
489    /// itself no longer discovers or reads anything on disk for the CLI
490    /// path. `bynkc`/the LSP still don't: `bynkc` routes through
491    /// `bynk-driver`, and the LSP (`bynk_check::analysis::analyse_project`
492    /// since P4.2) never goes through `CompileOptions` at all — it has its
493    /// own, separate discovery path (#1079). `bynk-emit`'s own tests also
494    /// set this, to exercise the full
495    /// project pipeline (cross-context `uses`, multi-file layouts,
496    /// workers-mode emission, …) without an on-disk fixture tree.
497    pub sources: Option<HashMap<PathBuf, String>>,
498    /// Events track, slice 3c (#980): reconcile against `bynk.schema.lock`.
499    /// **Off by default** — `bynkc compile`'s directory branch and `bynk`'s
500    /// deploy/dev build turn it on and write the reconciled lock back;
501    /// `check`'s and `test`'s directory paths (#2078) turn it on read-only,
502    /// so a non-additive change fails them too, and never write it. Every
503    /// library/test caller (in-memory builds, `bynkc/tests/e2e.rs`'s
504    /// in-place fixture compiles, `bynk-emit`'s own `sources`-driven tests,
505    /// the LSP) leaves it off, so a compile never mutates a project tree it
506    /// wasn't asked to.
507    ///
508    /// #1078: this carries the lock's pre-read content, not just an on/off
509    /// switch — `bynk-emit` reads and writes no disk for this file (the same
510    /// move #1077/#1081 already made for `.bynk` source content). See
511    /// [`SchemaLock`]'s own doc for the read side; the reconciled content
512    /// comes back out on [`ProjectOutput::schema_lock`] for the caller to
513    /// persist.
514    pub schema_registry: SchemaLock,
515    /// #1890: the project's extra Cloudflare compatibility flags, from
516    /// `bynk.toml`'s `[workers] compatibility_flags`, in manifest order.
517    /// A Workers build writes them into every `wrangler.toml` after
518    /// [`crate::DEFAULT_COMPATIBILITY_FLAGS`]; a duplicate is dropped with a
519    /// `bynk.project.duplicate_compatibility_flag` warning. Empty by default:
520    /// `bynk-driver`'s `project_options`/`try_project_options` read the
521    /// manifest and set it, as does `bynkc/tests/e2e.rs` for a fixture.
522    pub compatibility_flags: Vec<String>,
523    /// #1883, slice 1b: the project's `assets/` folder — its files, keyed by
524    /// normalised project-relative path (`assets/board.css`), listed by the
525    /// caller, and a reader for their bytes
526    /// ([`assets::AssetFolder`], usually `AssetFolder::on_disk`). The build
527    /// reads only the files an `asset` declaration names, once each, through
528    /// that reader, so `bynk-emit` never touches the disk and a large file
529    /// nothing declares is never read. `bynk-driver`'s
530    /// `project_options`/`try_project_options` and `bynk-testkit`'s
531    /// `compile_options_*` set it.
532    ///
533    /// `None` means no filesystem was consulted (`compile_in_memory`, the
534    /// playground). A check then skips the existence check; a build treats it
535    /// as an empty folder, so an asset there is `bynk.asset.missing_file`
536    /// rather than a URL made up for a file nobody read.
537    pub asset_files: Option<assets::AssetFolder>,
538    /// #2387, slice 2: the project's `embeds/` folder, listed by the caller
539    /// with a reader for the bytes of only the files an `embed` declaration
540    /// names ([`assets::AssetFolder::embeds_on_disk`]), exactly as
541    /// [`CompileOptions::asset_files`] does for `assets/`. `None` means no
542    /// filesystem was consulted: a check then skips the existence check, and a
543    /// build treats it as an empty folder, so an embed there is
544    /// `bynk.embed.missing_file` rather than text made up for a file nobody
545    /// read.
546    pub embed_files: Option<assets::EmbedFolder>,
547    /// #2138 (end-to-end testing track §3.5, §3.7): `Some` when `bynk test
548    /// --against` runs the `system` suites against a real runtime. The build
549    /// then emits only the `system` suites, routes their drivers to the
550    /// target's URLs (`BYNK_TEST_TARGETS`) instead of the in-process harness,
551    /// and refuses (or, under `--skip-unsupported`, skips) a case that uses
552    /// what a remote run can't honour. `None` for every other build.
553    pub remote_tests: Option<RemoteTests>,
554}
555
556/// #2138: how a remote test build treats a `system` case it can't run
557/// against a real runtime ([`CompileOptions::remote_tests`]).
558#[derive(Clone, Debug, Default, PartialEq, Eq)]
559pub struct RemoteTests {
560    /// `--skip-unsupported`: skip such a case, recording why
561    /// ([`DiscoveredSuite::skipped`]), instead of refusing it.
562    pub skip_unsupported: bool,
563    /// The named target's declared `vars`, which each case's `env` must agree
564    /// with (`bynk_check::test_env::remote_env_disagreement`).
565    pub vars: BTreeMap<String, String>,
566    /// The names of the named target's secrets, which a case's `env` may not
567    /// set.
568    pub secrets: Vec<String>,
569    /// `--against dev`: the runner starts the Workers itself and applies each
570    /// case's `env` to them, so `env` is not checked against `vars`; each
571    /// suite instead reports the distinct `env` maps its cases run with
572    /// ([`DiscoveredSuite::envs`]), one `bynk dev` per map.
573    pub dev: bool,
574    /// Under `dev`, build only the cases whose `env` is exactly this map: the
575    /// group one `bynk dev` serves.
576    pub only_env: Option<BTreeMap<String, String>>,
577}
578
579/// #2138: a `system` case a remote test build skipped, and why.
580#[derive(Clone, Debug, PartialEq, Eq)]
581pub struct RemoteSkip {
582    pub case: String,
583    /// The `bynk.test.remote_unsupported_*` code.
584    pub code: String,
585    /// The reason, as the refusal would have worded it.
586    pub reason: String,
587}
588
589impl CompileOptions {
590    /// Single-root project (`src == tests`), bundle target, default platform.
591    pub fn single(root: impl Into<PathBuf>) -> Self {
592        Self {
593            target: BuildTarget::Bundle,
594            platform: Platform::default(),
595            roots: Roots::Single(root.into()),
596            import_ext: ImportExt::default(),
597            contracts: false,
598            tests: true,
599            sources: None,
600            schema_registry: SchemaLock::Off,
601            compatibility_flags: Vec::new(),
602            asset_files: None,
603            embed_files: None,
604            remote_tests: None,
605        }
606    }
607
608    /// v0.9.1 split layout (source and test units in separate subdirectories
609    /// under `project_root`), bundle target, default platform. Use this from
610    /// `bynkc test` so its rooting matches `bynkc compile`'s.
611    pub fn split(project_root: impl Into<PathBuf>, paths: ProjectPaths) -> Self {
612        Self {
613            target: BuildTarget::Bundle,
614            platform: Platform::default(),
615            roots: Roots::Split {
616                project_root: project_root.into(),
617                paths,
618            },
619            import_ext: ImportExt::default(),
620            contracts: false,
621            tests: true,
622            sources: None,
623            schema_registry: SchemaLock::Off,
624            compatibility_flags: Vec::new(),
625            asset_files: None,
626            embed_files: None,
627            remote_tests: None,
628        }
629    }
630
631    /// #2138: a remote test build ([`CompileOptions::remote_tests`]).
632    pub fn remote_tests(mut self, remote: Option<RemoteTests>) -> Self {
633        self.remote_tests = remote;
634        self
635    }
636
637    /// Select the build target. `Bundle` (default) is the v0.6+ single-bundle
638    /// layout; `Workers` (v0.8) emits per-context Cloudflare Workers.
639    pub fn target(mut self, target: BuildTarget) -> Self {
640        self.target = target;
641        self
642    }
643
644    /// Slice 2: select the import-specifier extension. `Ts` is the debug build
645    /// for `bynkc test --inspect` (run the `.ts` directly under Node strip-only).
646    pub fn import_ext(mut self, ext: ImportExt) -> Self {
647        self.import_ext = ext;
648        self
649    }
650
651    /// v0.115: enable the function-contract call-site guard (dev/test profile).
652    /// `bynkc test` and `--inspect` call this; the deploy build leaves it off so
653    /// contract checks never reach production (DECISION J).
654    pub fn contracts(mut self, on: bool) -> Self {
655        self.contracts = on;
656        self
657    }
658
659    /// #1821 (ADR 0147 D3): whether to type-check and emit `suite`s. A build
660    /// that produces a deployable (`bynkc compile`, `bynk dev`/`deploy`) turns
661    /// it off; a suite is test-only.
662    pub fn tests(mut self, on: bool) -> Self {
663        self.tests = on;
664        self
665    }
666
667    /// v0.17: select the deploy [`Platform`] (selects the `bynk` surface
668    /// binding). The MVP ships `cloudflare` only.
669    pub fn platform(mut self, platform: Platform) -> Self {
670        self.platform = platform;
671        self
672    }
673
674    /// #57 (testing track): supply every file in-memory instead of walking
675    /// `roots` on disk — keyed the same way `discovery::read_source`'s
676    /// overlay is (see the `sources` field's own doc).
677    pub fn sources(mut self, sources: HashMap<PathBuf, String>) -> Self {
678        self.sources = Some(sources);
679        self
680    }
681
682    /// Events track, slice 3c (#980): turn on `bynk.schema.lock`
683    /// reconciliation for this build, with its pre-read content (or
684    /// verified-absent `None`, for a fresh project). See [`SchemaLock`] and
685    /// the field's own doc for who calls this and why everyone else leaves
686    /// it off.
687    pub fn schema_registry(mut self, mode: SchemaLock) -> Self {
688        self.schema_registry = mode;
689        self
690    }
691
692    /// #1890: the project's extra Cloudflare compatibility flags, from
693    /// `bynk.toml`'s `[workers]` table. See the field's own doc.
694    pub fn compatibility_flags(mut self, flags: Vec<String>) -> Self {
695        self.compatibility_flags = flags;
696        self
697    }
698
699    /// #1883, slice 1b: the listed `assets/` folder. See the field's own doc.
700    pub fn asset_files(mut self, folder: assets::AssetFolder) -> Self {
701        self.asset_files = Some(folder);
702        self
703    }
704
705    /// #2387, slice 2: the listed `embeds/` folder. See the field's own doc.
706    pub fn embed_files(mut self, folder: assets::EmbedFolder) -> Self {
707        self.embed_files = Some(folder);
708        self
709    }
710}
711
712/// #57: turn `options.sources` into the `(overlay, discovered)` pair
713/// `run_checks` expects — the caller-supplied file list, partitioned across
714/// `trees` the same way a real walk would (single-tree: every file lands in
715/// the first tree's list, matching `compile_in_memory`'s own convention).
716/// Shared by [`compile_project`] and [`check_project`] so the two can't drift
717/// on this.
718///
719/// #1077/#1081 review: sorts every partition. `sources`'s own key order is a
720/// `HashMap`'s — unspecified, randomised per process — but `phase_parse`
721/// walks each tree's file list in order to assign sequential `FileId`s and
722/// `ExprId`s (embedded in emitted spans/source maps) and `run_checks` pushes
723/// diagnostics in file order, both of which a real disk walk already
724/// guaranteed via `discover_bynk_files`'s own `out.sort()`. Without this, a
725/// `sources`-driven compile (the CLI's own path as of #1081) would silently
726/// vary its diagnostic order and `FileId` assignment run to run.
727///
728/// R3.9 (#1113): partitions across every `trees` entry, not a hardcoded
729/// primary/secondary pair — a path that doesn't start with any tree's root
730/// (shouldn't happen for a well-formed `sources` map) falls back to the
731/// *last* tree, matching the pre-R3.9 two-tree `partition`'s fallback (an
732/// unmatched key landed in `tests_files`, the second/last half of the pair).
733/// Falling back to the *first* tree instead — silently tried during this
734/// slice's initial cut — would move an unmatched file into whichever tree
735/// `check_file_directory_conflicts` and `identity_path` treat as primary,
736/// changing its attribution with no diagnostic raised either way; matching
737/// the old convention at least keeps this rare path's behaviour unchanged by
738/// the crate move.
739type Overlay = HashMap<PathBuf, String>;
740/// One file list per `trees` entry — the same shape `phase_discovery` and
741/// `run_checks`'s own `discovered` parameter already use.
742type Discovered = Vec<Vec<PathBuf>>;
743
744/// #2223: the trees a manifest-backed project compiles — its own
745/// ([`Roots::trees`]) and then each path dependency's
746/// ([`bynk_project::dependency_trees`]) — so the source map partitions a
747/// dependency's files into their own tree rather than the last one.
748fn project_trees(options: &CompileOptions) -> Vec<(PathBuf, PathBuf)> {
749    let mut trees = options.roots.trees();
750    if let Roots::Split { project_root, .. } = &options.roots {
751        let empty = HashMap::new();
752        let overlay = options.sources.as_ref().unwrap_or(&empty);
753        trees.extend(bynk_project::dependency_trees(project_root, overlay));
754    }
755    trees
756}
757
758fn sources_to_discovered(
759    sources: &HashMap<PathBuf, String>,
760    trees: &[(PathBuf, PathBuf)],
761) -> (Overlay, Option<Discovered>) {
762    let mut buckets: Vec<Vec<PathBuf>> = vec![Vec::new(); trees.len()];
763    let mut keys: Vec<PathBuf> = sources.keys().cloned().collect();
764    keys.sort();
765    for p in keys {
766        let idx = trees
767            .iter()
768            .position(|(root, _)| p.starts_with(root))
769            .unwrap_or(trees.len().saturating_sub(1));
770        buckets[idx].push(p);
771    }
772    (sources.clone(), Some(buckets))
773}
774
775/// Compile a Bynk project, keeping error attribution + snapshots on failure
776/// (so the CLI can render project errors with source context, ADR 0052). Use
777/// `.map_err(ProjectFailure::flatten)` for the flattened `Vec<CompileError>`
778/// shape.
779pub fn compile_project(options: &CompileOptions) -> Result<ProjectOutput, ProjectFailure> {
780    // T3.6b (R4.1): one table per build, shared across every unit compiled.
781    let tys = &Arc::new(Types::new());
782    let trees = project_trees(options);
783    let excludes = options.roots.excludes();
784    let (overlay, discovered) = match &options.sources {
785        Some(sources) => sources_to_discovered(sources, &trees),
786        None => (HashMap::new(), None),
787    };
788    let run = run_checks(
789        &trees,
790        options.target,
791        options.platform,
792        options.import_ext,
793        Mode::Build,
794        &overlay,
795        &excludes,
796        discovered,
797        options.contracts,
798        options.tests,
799        &options.schema_registry,
800        options.remote_tests.as_ref(),
801        options.roots.project_root(),
802        options.asset_files.as_ref(),
803        options.embed_files.as_ref(),
804        matches!(options.roots, Roots::Split { .. }),
805        tys,
806    );
807    // #1078: `bynk-emit` no longer writes `bynk.schema.lock` itself — the
808    // reconciled content comes back on `ProjectOutput::schema_lock`
809    // (`finish_build` populates it from `RunChecks::Checked`, only ever
810    // constructed on the `Ok` path, i.e. only on a fully clean build — a
811    // build that fails for any reason, including a schema mismatch
812    // reconciliation itself just reported, produces `Err(ProjectFailure)`
813    // instead and has no revised content for a caller to persist). The
814    // caller (today, `bynk-driver`'s two wiring points) does the atomic
815    // write.
816    finish_build(
817        run,
818        options.import_ext,
819        options.roots.project_root(),
820        &options.compatibility_flags,
821    )
822}
823
824/// Result of [`check_project`]: every diagnostic from a non-bailing project
825/// analysis — errors *and* warnings together (ADR 0117), unconditionally,
826/// unlike [`ProjectOutput`]/[`ProjectFailure`] where `errors`/`warnings` is
827/// picked by which variant the caller got. `bynk check`'s exit code is
828/// decided by [`Self::has_errors`], not by whether this was reached at all.
829pub struct ProjectCheck {
830    pub errors: Vec<AttributedError>,
831    pub snapshots: Vec<(PathBuf, String)>,
832    /// #1772: the project or tree root the build was given, as the caller
833    /// spelled it. `snapshots` and each error's `source_path` are keyed by
834    /// identity path, relative to this root; a renderer shows
835    /// `display_root.join(identity)`, the path as typed from the working
836    /// directory. Empty for an in-memory build.
837    pub display_root: PathBuf,
838    /// State-migrations S4 (#2259): each agent class's lock version, oldest
839    /// readable version and valid `migrate` steps, keyed `<unit>.<Agent>`.
840    /// Empty without a lock, or when the check stopped before reconciling.
841    pub agent_versions: HashMap<String, bynk_check::schema_registry::AgentVersions>,
842}
843
844impl ProjectCheck {
845    /// Finding #64: `bynk check`'s exit-code gate is "does any error-severity
846    /// diagnostic exist in the project" — not "did the pipeline reach the end
847    /// without bailing", which is what `compile_project`'s `Result` encoded
848    /// and why a `bynk.toml`-wide structural error anywhere silently hid every
849    /// later diagnostic (including a test body's own type errors) from
850    /// `bynk check`, even though the editor (via
851    /// `bynk_check::analysis::analyse_project`, which shares this same
852    /// non-bailing shape) still reported them.
853    pub fn has_errors(&self) -> bool {
854        self.errors
855            .iter()
856            .any(|ae| bynk_syntax::Severity::for_error(&ae.error) == bynk_syntax::Severity::Error)
857    }
858}
859
860/// Check a project without building (finding #64) — never bails after
861/// discovery (`Mode::Analyse`, the same mode `bynk_check::analysis::analyse_project`
862/// runs for the editor), so a diagnostic anywhere in the project does not
863/// suppress diagnostics elsewhere. `compile_project`'s `Mode::Build` bails at
864/// the first structural error and returns only what it collected up to that
865/// point — correct for `build`/`test`, which must not emit past a real
866/// error, but wrong for `check`, whose only job is to report everything.
867/// `bynk check`'s directory path calls this instead of `compile_project`.
868pub fn check_project(options: &CompileOptions) -> ProjectCheck {
869    // T3.6b (R4.1): one table per check, shared across every unit.
870    let tys = &Arc::new(Types::new());
871    let trees = project_trees(options);
872    let excludes = options.roots.excludes();
873    let (overlay, discovered) = match &options.sources {
874        Some(sources) => sources_to_discovered(sources, &trees),
875        None => (HashMap::new(), None),
876    };
877    let run = run_checks(
878        &trees,
879        options.target,
880        options.platform,
881        options.import_ext,
882        Mode::Analyse,
883        &overlay,
884        &excludes,
885        discovered,
886        options.contracts,
887        options.tests,
888        // #2078: `bynk check` reconciles against the committed
889        // `bynk.schema.lock` whenever the caller pre-read it
890        // (`SchemaLock::On`), so a non-additive event change fails the check
891        // the way it fails a build. Read-only by construction: a
892        // `ProjectCheck` carries no reconciled content for anyone to write.
893        &options.schema_registry,
894        options.remote_tests.as_ref(),
895        options.roots.project_root(),
896        options.asset_files.as_ref(),
897        options.embed_files.as_ref(),
898        matches!(options.roots, Roots::Split { .. }),
899        tys,
900    );
901    let (errors, snapshots, agent_versions) = match run {
902        RunChecks::Bailed {
903            errors, snapshots, ..
904        } => (errors, snapshots, HashMap::new()),
905        RunChecks::Checked {
906            errors,
907            snapshots,
908            agent_versions,
909            ..
910        } => (errors, snapshots, agent_versions),
911    };
912    ProjectCheck {
913        // #1890: the manifest's duplicate-flag warnings, as a build reports.
914        errors: errors
915            .into_all()
916            .into_iter()
917            .chain(compatibility_flag_warnings(&options.compatibility_flags))
918            .collect(),
919        snapshots,
920        display_root: options.roots.project_root().to_path_buf(),
921        agent_versions,
922    }
923}
924
925/// Compile a single **in-memory** Bynk source through the full project pipeline —
926/// no filesystem access (in-browser track, slice 3). The source is the in-process
927/// `Bundle` subset that `consumes bynk`; first-party injection and the per-platform
928/// binding emission run exactly as for an on-disk build, so the returned
929/// [`ProjectOutput`] is the complete module graph (the user unit + `runtime.ts` +
930/// the `bynk-<platform>.ts` binding + `compose.ts`). The wasm entry point pairs
931/// this with `bynk-strip` to produce JavaScript for the playground.
932///
933/// The module's logical path is **derived from its declared unit name** (a context
934/// `app.demo` ⇒ `app/demo.bynk`), so the name↔path alignment check passes without
935/// real files; a source that does not parse falls back to `main.bynk` and the parse
936/// error is reported normally.
937pub fn compile_in_memory(
938    source: &str,
939    target: BuildTarget,
940    platform: Platform,
941) -> Result<ProjectOutput, ProjectFailure> {
942    // T3.6b (R4.1): one table for this virtual project's compile.
943    let tys = &Arc::new(Types::new());
944    // A single-tree (`src_root == tests_root`) virtual project rooted at `.`: the
945    // one source file is supplied directly and its text layered in via the
946    // overlay, so discovery and every other disk read are bypassed.
947    let root = PathBuf::from(".");
948    let path = in_memory_logical_path(source);
949    let mut overlay = HashMap::new();
950    overlay.insert(path.clone(), source.to_string());
951    let trees = vec![(root.clone(), PathBuf::new())];
952    let run = run_checks(
953        &trees,
954        target,
955        platform,
956        ImportExt::Js,
957        Mode::Build,
958        &overlay,
959        &[],
960        Some(vec![vec![path]]),
961        false,
962        true,
963        &SchemaLock::Off,
964        None,
965        &root,
966        None,
967        None,
968        false,
969        tys,
970    );
971    finish_build(run, ImportExt::Js, Path::new(""), &[])
972}
973
974/// [`compile_in_memory`] for several files (#1830): each `(path, source)` pair
975/// is one project file, its path relative to a single-tree root exactly as on
976/// disk (`demo/shop.bynk` for `context demo.shop`, `tests/shop.bynk` for a
977/// suite). Test-only, so a unit test can build a multi-unit project, or a
978/// unit with its suite, without a fixture directory; reach it through
979/// `crate::testkit`. `tests` keeps the suites, as `bynkc test` does; off is
980/// the deployable build `bynkc compile` makes.
981#[cfg(test)]
982pub(crate) fn compile_files_in_memory(
983    files: &[(&str, &str)],
984    target: BuildTarget,
985    platform: Platform,
986    tests: bool,
987) -> Result<ProjectOutput, ProjectFailure> {
988    compile_files_in_memory_remote(files, target, platform, tests, None, None)
989}
990
991/// [`compile_files_in_memory`] as a remote test build (#2138,
992/// [`CompileOptions::remote_tests`]).
993#[cfg(test)]
994pub(crate) fn compile_files_in_memory_remote(
995    files: &[(&str, &str)],
996    target: BuildTarget,
997    platform: Platform,
998    tests: bool,
999    remote: Option<&RemoteTests>,
1000    asset_files: Option<&assets::AssetFolder>,
1001) -> Result<ProjectOutput, ProjectFailure> {
1002    let tys = &Arc::new(Types::new());
1003    let root = PathBuf::from(".");
1004    let paths: Vec<PathBuf> = files.iter().map(|(p, _)| PathBuf::from(p)).collect();
1005    let overlay: HashMap<PathBuf, String> = files
1006        .iter()
1007        .map(|(p, src)| (PathBuf::from(p), (*src).to_string()))
1008        .collect();
1009    let trees = vec![(root.clone(), PathBuf::new())];
1010    let run = run_checks(
1011        &trees,
1012        target,
1013        platform,
1014        ImportExt::Js,
1015        Mode::Build,
1016        &overlay,
1017        &[],
1018        Some(vec![paths]),
1019        false,
1020        tests,
1021        &SchemaLock::Off,
1022        remote,
1023        &root,
1024        asset_files,
1025        None,
1026        false,
1027        tys,
1028    );
1029    finish_build(run, ImportExt::Js, Path::new(""), &[])
1030}
1031
1032/// Analyse a single **in-memory** Bynk source and return all diagnostics —
1033/// non-bailing, no emission (in-browser track, slice 5d). The editor calls this
1034/// on every (debounced) keystroke for live diagnostics: unlike [`compile_in_memory`]
1035/// (build mode, which bails at the first failing phase), this runs in `Analyse`
1036/// mode, so parse / resolve / check diagnostics are recovered and reported together
1037/// — and it works for a `context` (the playground's typical program), not only a
1038/// commons. Same fs-free seam as `compile_in_memory`.
1039pub fn analyse_in_memory(
1040    source: &str,
1041    target: BuildTarget,
1042    platform: Platform,
1043) -> Vec<AttributedError> {
1044    analyse_in_memory_with_types(source, target, platform).errors
1045}
1046
1047/// The outcome of [`analyse_in_memory_with_types`]: diagnostics plus the
1048/// analysed file's `(span, type)` entries (span-sorted — see
1049/// [`ExprTypeSink::take_files`]), for a position→type query (#397, the
1050/// playground's hover), and its local bindings (#808, the playground's
1051/// completion — `bynk_check::locals::locals_at` over `locals` answers
1052/// "what's in scope at this offset").
1053pub struct InMemoryAnalysis {
1054    pub errors: Vec<AttributedError>,
1055    pub expr_types: Vec<(bynk_syntax::span::Span, TyId)>,
1056    /// T3.6b (R4.1): the table `expr_types`' ids resolve against.
1057    pub ty_intern: std::sync::Arc<bynk_check::checker::Types>,
1058    pub locals: Vec<bynk_check::locals::LocalBinding>,
1059}
1060
1061/// Like [`analyse_in_memory`], but also exposes the expression-type map the
1062/// checker captured (ADR 0063's `expr_types` sink) — the same one
1063/// `bynk_check::analysis::analyse_project` drains — instead of discarding it. Per ADR 0094,
1064/// this is a best-effort **partial** map in `Analyse` mode: a function that
1065/// type-checked cleanly contributes its types even if a *different* function
1066/// in the same file has an error, so `expr_types` is empty only when the
1067/// expression at hand never typed at all (e.g. it sits in an unresolved
1068/// region, ADR 0094's "out of scope — the resolve gate"), not merely because
1069/// the file has some error somewhere.
1070pub fn analyse_in_memory_with_types(
1071    source: &str,
1072    target: BuildTarget,
1073    platform: Platform,
1074) -> InMemoryAnalysis {
1075    // T3.6b (R4.1): one table for the whole analysis — every unit it checks
1076    // interns into this, so the `TyId`s it hands back on `InMemoryAnalysis`
1077    // all resolve against the one table it also hands back.
1078    let tys = &Arc::new(Types::new());
1079    let root = PathBuf::from(".");
1080    let path = in_memory_logical_path(source);
1081    let mut overlay = HashMap::new();
1082    overlay.insert(path.clone(), source.to_string());
1083    let trees = vec![(root.clone(), PathBuf::new())];
1084    let run = run_checks(
1085        &trees,
1086        target,
1087        platform,
1088        ImportExt::Js,
1089        Mode::Analyse,
1090        &overlay,
1091        &[],
1092        Some(vec![vec![path.clone()]]),
1093        false,
1094        true,
1095        &SchemaLock::Off,
1096        None,
1097        &root,
1098        None,
1099        None,
1100        false,
1101        tys,
1102    );
1103    match run {
1104        RunChecks::Bailed {
1105            errors,
1106            mut exprs,
1107            mut locals,
1108            ..
1109        }
1110        | RunChecks::Checked {
1111            errors,
1112            mut exprs,
1113            mut locals,
1114            ..
1115        } => InMemoryAnalysis {
1116            errors: errors.into_all(),
1117            expr_types: exprs.take_files().remove(&path).unwrap_or_default(),
1118            ty_intern: Arc::clone(tys),
1119            locals: locals.take_files().remove(&path).unwrap_or_default(),
1120        },
1121    }
1122}
1123
1124/// The outcome of [`analyse_in_memory_project`]: every file's diagnostics,
1125/// plus each `.bynk` file's expression types and local bindings keyed by its
1126/// project-relative path, for a position→type query in any of them.
1127pub struct InMemoryProjectAnalysis {
1128    pub errors: Vec<AttributedError>,
1129    pub expr_types: HashMap<PathBuf, Vec<(bynk_syntax::span::Span, TyId)>>,
1130    /// The table every file's `expr_types` ids resolve against.
1131    pub ty_intern: std::sync::Arc<bynk_check::checker::Types>,
1132    pub locals: HashMap<PathBuf, Vec<bynk_check::locals::LocalBinding>>,
1133    /// `bynk.toml` `[paths]` entries that were ignored, as `(key, entry)`
1134    /// (`("include", "/abs/src")`): an absolute `include` or `exclude` can't
1135    /// name anything in an in-memory project, so rather than silently matching
1136    /// nothing it is reported here for the caller to surface. Plain data, not a
1137    /// message: wording is the caller's.
1138    pub ignored_paths: Vec<(String, String)>,
1139}
1140
1141/// #2102 slice 2: analyse a whole project held in memory, for the VS Code web
1142/// extension, which reads the workspace through `vscode.workspace.fs` and has
1143/// no filesystem to hand `run_checks`.
1144///
1145/// `files` maps **project-relative** paths to their text: `bynk.toml` (when the
1146/// project has one) and every `.bynk` file, at the paths they have on disk
1147/// (`src/app/web.bynk`). The project root is the empty path, so every path
1148/// stays relative, in the diagnostics too. `[paths] include`/`exclude` come
1149/// from the in-memory `bynk.toml`. With no `include`, the conventional layout
1150/// is decided from the files themselves (`src`, `tests`, else the root) rather
1151/// than [`ProjectPaths::conventional`]'s disk probe. Non-`.bynk` entries other
1152/// than `bynk.toml` are ignored. Pure: no filesystem.
1153pub fn analyse_in_memory_project(
1154    files: &HashMap<PathBuf, String>,
1155    target: BuildTarget,
1156    platform: Platform,
1157) -> InMemoryProjectAnalysis {
1158    let tys = &Arc::new(Types::new());
1159    let project_root = PathBuf::new();
1160    // Keys are `/`-separated whatever the caller's platform spelling.
1161    let files: &HashMap<PathBuf, String> = &files
1162        .iter()
1163        .map(|(path, text)| (slash_key(path), text.clone()))
1164        .collect();
1165    let (include, exclude, ignored_paths) = in_memory_project_paths(files);
1166
1167    // One tree per distinct include, with its prefix (`.` is the empty one —
1168    // see `Roots::trees`). Built here rather than through `Roots` so a `.`
1169    // root stays the empty path, which every relative key starts with.
1170    let mut trees: Vec<(PathBuf, PathBuf)> = Vec::new();
1171    for root in include {
1172        if !trees.iter().any(|(r, _)| *r == root) {
1173            trees.push((root.clone(), root));
1174        }
1175    }
1176    let mut excludes: Vec<PathBuf> = exclude;
1177    excludes.extend(["out", "node_modules"].map(PathBuf::from));
1178
1179    let mut buckets: Vec<Vec<PathBuf>> = vec![Vec::new(); trees.len()];
1180    let mut keys: Vec<&PathBuf> = files
1181        .keys()
1182        .filter(|p| p.extension().is_some_and(|e| e == "bynk"))
1183        .filter(|p| !excludes.iter().any(|ex| p.starts_with(ex)))
1184        .collect();
1185    keys.sort();
1186    for p in keys {
1187        if let Some(idx) = trees.iter().position(|(root, _)| p.starts_with(root)) {
1188            buckets[idx].push(p.clone());
1189        }
1190    }
1191
1192    let run = run_checks(
1193        &trees,
1194        target,
1195        platform,
1196        ImportExt::Js,
1197        Mode::Analyse,
1198        files,
1199        &[],
1200        Some(buckets),
1201        false,
1202        true,
1203        &SchemaLock::Off,
1204        None,
1205        &project_root,
1206        None,
1207        None,
1208        false,
1209        tys,
1210    );
1211    match run {
1212        RunChecks::Bailed {
1213            errors,
1214            mut exprs,
1215            mut locals,
1216            ..
1217        }
1218        | RunChecks::Checked {
1219            errors,
1220            mut exprs,
1221            mut locals,
1222            ..
1223        } => InMemoryProjectAnalysis {
1224            errors: errors
1225                .into_all()
1226                .into_iter()
1227                .map(|mut e| {
1228                    e.source_path = e.source_path.as_deref().map(slash_key);
1229                    for label in &mut e.label_files {
1230                        if let LabelFile::Other(p) = label {
1231                            *p = slash_key(p);
1232                        }
1233                    }
1234                    e
1235                })
1236                .collect(),
1237            expr_types: exprs
1238                .take_files()
1239                .into_iter()
1240                .map(|(p, v)| (slash_key(&p), v))
1241                .collect(),
1242            ty_intern: Arc::clone(tys),
1243            locals: locals
1244                .take_files()
1245                .into_iter()
1246                .map(|(p, v)| (slash_key(&p), v))
1247                .collect(),
1248            ignored_paths,
1249        },
1250    }
1251}
1252
1253/// A path as an in-memory project key: its components joined with `/`.
1254///
1255/// [`analyse_in_memory_project`]'s keys are `/`-separated on every platform
1256/// (they come from the web extension, which speaks URIs). The project model
1257/// builds identity paths with `PathBuf::join`, which inserts `\` on Windows
1258/// (`src` + `app/web.bynk` is `src\app/web.bynk` there), so every path
1259/// handed back is rebuilt this way, and keys taken in are too. On Unix a `\`
1260/// is an ordinary file-name character, not a separator, and is kept.
1261pub fn slash_key(path: &Path) -> PathBuf {
1262    let parts: Vec<String> = path
1263        .components()
1264        .filter(|c| !matches!(c, Component::CurDir))
1265        .map(|c| c.as_os_str().to_string_lossy().into_owned())
1266        .collect();
1267    PathBuf::from(parts.join("/"))
1268}
1269
1270/// The `[paths]` `include` and `exclude` lists of an in-memory project (see
1271/// [`analyse_in_memory_project`]). An absent or unreadable `bynk.toml`, or one
1272/// with no `include`, gets the conventional layout, judged by which of `src/`
1273/// and `tests/` hold a `.bynk` file. Entries are normalised with [`slash_key`],
1274/// so `./src` is `src` and the root (`.`) is the empty path. An absolute entry
1275/// is dropped and described in the third list (an `include` left empty by that
1276/// falls back to the conventional layout).
1277type IgnoredPaths = Vec<(String, String)>;
1278
1279fn in_memory_project_paths(
1280    files: &HashMap<PathBuf, String>,
1281) -> (Vec<PathBuf>, Vec<PathBuf>, IgnoredPaths) {
1282    let doc = files
1283        .get(Path::new("bynk.toml"))
1284        .and_then(|text| text.parse::<toml::Table>().ok());
1285    let paths = doc
1286        .as_ref()
1287        .and_then(|d| d.get("paths"))
1288        .and_then(|v| v.as_table());
1289    // Each entry is normalised like the file keys (`./src` is `src`), so it
1290    // prefixes them. The project root itself (`.`, `./`) normalises to the
1291    // empty path, which prefixes every key.
1292    let mut ignored = Vec::new();
1293    let mut list = |key: &str| -> Vec<PathBuf> {
1294        let entries: Vec<&str> = match paths.and_then(|t| t.get(key)) {
1295            Some(toml::Value::Array(items)) => items.iter().filter_map(|v| v.as_str()).collect(),
1296            Some(toml::Value::String(s)) => vec![s.as_str()],
1297            _ => Vec::new(),
1298        };
1299        let mut kept = Vec::new();
1300        for entry in entries {
1301            if is_absolute_entry(entry) {
1302                ignored.push((key.to_string(), entry.to_string()));
1303            } else {
1304                kept.push(slash_key(Path::new(entry)));
1305            }
1306        }
1307        kept
1308    };
1309    let mut include = list("include");
1310    let exclude = list("exclude");
1311    if include.is_empty() {
1312        let has = |dir: &str| {
1313            files
1314                .keys()
1315                .any(|p| p.starts_with(dir) && p.extension().is_some_and(|e| e == "bynk"))
1316        };
1317        include = ["src", "tests"]
1318            .into_iter()
1319            .filter(|d| has(d))
1320            .map(PathBuf::from)
1321            .collect();
1322        if include.is_empty() {
1323            include.push(PathBuf::new());
1324        }
1325    }
1326    (include, exclude, ignored)
1327}
1328
1329/// Whether a `[paths]` entry is absolute on any platform: rooted (`/src`,
1330/// `\\src`) or with a drive (`C:/src`), whatever the host's own path rules.
1331fn is_absolute_entry(entry: &str) -> bool {
1332    let bytes = entry.as_bytes();
1333    entry.starts_with('/')
1334        || entry.starts_with('\\')
1335        || (bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':')
1336        || Path::new(entry).has_root()
1337}
1338
1339/// Derive the conventional single-file path for an in-memory source from its
1340/// declared unit name (`app.demo` ⇒ `app/demo.bynk`), so `check_path_name_alignment`
1341/// is satisfied without a real file tree. Falls back to `main.bynk` when the source
1342/// does not parse — `run_checks` then re-parses and reports the error against it.
1343fn in_memory_logical_path(source: &str) -> PathBuf {
1344    let parts: Option<Vec<String>> = lexer::tokenize(source)
1345        .ok()
1346        .and_then(|tokens| parser::parse_unit(&tokens, source).ok())
1347        .map(|unit| unit.name().parts.iter().map(|i| i.name.clone()).collect());
1348    match parts {
1349        Some(p) if !p.is_empty() => {
1350            let mut path = PathBuf::from(p.join("/"));
1351            path.set_extension("bynk");
1352            path
1353        }
1354        _ => PathBuf::from("main.bynk"),
1355    }
1356}
1357
1358/// #1890: one `bynk.project.duplicate_compatibility_flag` warning per
1359/// duplicate in the manifest's `[workers] compatibility_flags` (`extra`),
1360/// shared by [`finish_build`] and [`check_project`] so `compile` and `check`
1361/// report the same thing. Attributed to `bynk.toml` by its root-relative
1362/// identity path, like every other [`AttributedError`]; the manifest is never
1363/// a snapshot, so renderers print that path (or none) rather than a source
1364/// excerpt, and the message names `bynk.toml` itself.
1365fn compatibility_flag_warnings(extra: &[String]) -> Vec<AttributedError> {
1366    let (_, duplicates) = crate::compatibility_flags(extra);
1367    duplicates
1368        .into_iter()
1369        .map(|flag| {
1370            let default = crate::DEFAULT_COMPATIBILITY_FLAGS.contains(&flag.as_str());
1371            bynk_project::duplicate_compatibility_flag_warning(
1372                &flag,
1373                default,
1374                PathBuf::from("bynk.toml"),
1375            )
1376        })
1377        .collect()
1378}
1379
1380/// Assemble a finished [`ProjectOutput`] (or a [`ProjectFailure`]) from a
1381/// [`RunChecks`] result — the shared tail of `compile_project` and
1382/// `compile_in_memory`.
1383///
1384/// #1890: `compatibility_flags` is the manifest's `[workers]` list
1385/// ([`CompileOptions::compatibility_flags`]). It is merged with the defaults
1386/// once here, for every Worker, and each duplicate it held becomes one
1387/// [`compatibility_flag_warnings`] warning, whatever the build target: the
1388/// duplicate is a fact about the manifest.
1389fn finish_build(
1390    run: RunChecks,
1391    import_ext: ImportExt,
1392    display_root: &Path,
1393    compatibility_flags: &[String],
1394) -> Result<ProjectOutput, ProjectFailure> {
1395    let (flags, _) = crate::compatibility_flags(compatibility_flags);
1396    let flag_warnings = compatibility_flag_warnings(compatibility_flags);
1397    match run {
1398        RunChecks::Bailed {
1399            errors, snapshots, ..
1400        } => Err(ProjectFailure {
1401            // ADR 0117: a failed build still renders any warnings it produced
1402            // (the sink yields errors then warnings).
1403            errors: errors.into_all().into_iter().chain(flag_warnings).collect(),
1404            snapshots,
1405            display_root: display_root.to_path_buf(),
1406        }),
1407        RunChecks::Checked {
1408            errors, snapshots, ..
1409        } if !errors.is_empty() => Err(ProjectFailure {
1410            errors: errors.into_all().into_iter().chain(flag_warnings).collect(),
1411            snapshots,
1412            display_root: display_root.to_path_buf(),
1413        }),
1414        RunChecks::Checked {
1415            errors,
1416            snapshots,
1417            parsed,
1418            compiled,
1419            runnable_tests,
1420            integration_outputs,
1421            integration_runnables,
1422            groups,
1423            kinds,
1424            unit_consumes,
1425            unit_consumes_aliases,
1426            unit_tables,
1427            unit_consumed_context_types,
1428            unit_callees,
1429            unit_event_subscriber_shapes,
1430            unit_uses,
1431            unit_flattened,
1432            adapter_bindings,
1433            npm_deps,
1434            target,
1435            schema_registry,
1436            asset_bytes,
1437            agent_versions,
1438            ..
1439        } => {
1440            let mut out = build_output(
1441                parsed,
1442                compiled,
1443                runnable_tests,
1444                integration_outputs,
1445                integration_runnables,
1446                groups,
1447                kinds,
1448                unit_consumes,
1449                unit_consumes_aliases,
1450                unit_tables,
1451                &unit_consumed_context_types,
1452                unit_callees,
1453                unit_event_subscriber_shapes,
1454                unit_uses,
1455                unit_flattened,
1456                adapter_bindings,
1457                npm_deps,
1458                target,
1459                import_ext,
1460                &flags,
1461                asset_bytes.as_ref(),
1462                &agent_versions,
1463            );
1464            // ADR 0117: surface non-failing warnings on the successful build
1465            // (errors is empty here — the guard arm above caught any).
1466            out.warnings = errors.into_warnings();
1467            out.warnings.extend(flag_warnings);
1468            out.snapshots = snapshots;
1469            out.display_root = display_root.to_path_buf();
1470            // #1078: the reconciled registry, if this build had one on —
1471            // bynk-emit computes it, the caller persists it.
1472            out.schema_lock = schema_registry.map(|reg| schema_registry::serialize(&reg));
1473            Ok(out)
1474        }
1475    }
1476}
1477
1478// P4.1 (#1115): `normalize_service_defaults`/`inject_service_defaults`
1479// relocated to `bynk-check::project_model` (called from both `run_checks`
1480// here and the new `bynk-check`-native analysis entry point, ahead of
1481// `phase_group` in both).
1482
1483/// v0.54 (#655): whether a context's services declare an `on call … by c: Caller`
1484/// handler, whose emitted `deps` carries the calling context's qualified name as
1485/// its `CallerId` identity (ADR 0092); in bundle mode the compose root supplies
1486/// that name to `__makeSurface`, mirroring the `X-Bynk-Caller` header a Worker
1487/// reads at its entry. Delegates to the *same*
1488/// [`any_service_binds_caller`](crate::emitter::any_service_binds_caller) the
1489/// emitter's `emit_make_surface` calls, so the compose root and the surface can
1490/// never disagree on which providers take the extra `__caller` argument.
1491fn context_binds_caller(table: &UnitTable) -> bool {
1492    crate::emitter::any_service_binds_caller(table.services.values(), &table.actors)
1493}
1494
1495// P4.1 (#1115): `record_analyse_types` relocated to
1496// `bynk-check::check_pipeline` (called from `check_file_core`'s own
1497// error-path exits, plus every clean-path caller — `check_unit_files`'s
1498// `Mode::Analyse` branch below and the new entry point's own).
1499
1500// P4.1 (#1115): the whole discovery->parse->group->resolve pipeline
1501// (`phase_discovery` through `phase_file_index`/`assemble_unit_info`, plus
1502// per-unit symbol composition — `compose_unit_symbols`/
1503// `merge_consumed_exports`/`collect_unit_methods`) relocated to
1504// `bynk-check::project_model` — see that module's own doc comment for why
1505// (the same `extract, don't duplicate` move `bynk-project` itself was, P4.0).
1506// `run_checks`, below, is now a caller of `project_model::phase_*` instead of
1507// owning this logic inline.
1508
1509/// v0.119 (ADR 0155): the agents a `for all run: History[Agent]` property drives,
1510/// scanned across every test suite in the project. `emit_agent` gates the
1511/// exported `__bynkDriveHistory_<Agent>` driver on membership, so a non-targeted
1512/// agent's emission is unchanged.
1513fn collect_history_target_agents(parsed: &[ParsedFile]) -> HashSet<String> {
1514    parsed
1515        .iter()
1516        .flat_map(|pf| pf.history_target_agent_names())
1517        .map(String::from)
1518        .collect()
1519}
1520
1521/// Phase 8e: build the emitter context for one checked source file and render
1522/// its TypeScript, pushing the result onto `compiled`. Reached only in build
1523/// mode (the caller's analyse-mode `continue` gates this off); the block is
1524/// straight-line with no `continue`s of its own.
1525#[allow(clippy::too_many_arguments)]
1526/// Emit-prologue tables that depend only on the *unit* (`name`/`unit_info`/
1527/// `target`) — never on which file within the unit is being emitted. Building
1528/// one of these once per unit, ahead of the per-file loop, replaces what used
1529/// to be an identical rebuild (several nested nested loops over `unit_info`)
1530/// on every emitted file of a multi-file context.
1531struct EmitUnitCtx {
1532    imported_methods: HashMap<String, Vec<FnSig>>,
1533    /// The workers-mode-rewritten view is the only one `emit_unit` reads —
1534    /// the pre-rewrite table is an intermediate of computing it, not exposed
1535    /// separately.
1536    imported_decl_paths_emit: HashMap<String, HashMap<String, PathBuf>>,
1537    exports_for_consumed: HashMap<String, HashMap<String, Visibility>>,
1538    file_decl_index: FileDeclIndex,
1539    /// #1820: a context split across files, built for Workers. Each file is
1540    /// emitted as its own module ([`worker_file_source_path`]) and
1541    /// `handlers.ts` re-exports them ([`emit_worker_handlers_barrel`]), rather
1542    /// than every file being written to `handlers.ts` in turn, each
1543    /// overwriting the last. `file_decl_index` then names each declaration's
1544    /// module, so one file imports what it uses from another.
1545    split_worker: bool,
1546}
1547
1548fn build_emit_unit_ctx(
1549    name: &str,
1550    unit_info: &BTreeMap<String, UnitInfo>,
1551    hidden_types: &BTreeMap<String, String>,
1552    target: BuildTarget,
1553    tys: &Arc<Types>,
1554    split_worker: bool,
1555) -> EmitUnitCtx {
1556    let info = &unit_info[name];
1557    // v0.132.1 (#481): gather the attached methods of every `uses`-imported type
1558    // (one level, matching the symbol-table merge). `emit_context_rebrands`
1559    // forwards these onto the consumer's rebranded const so a call like
1560    // `Cents.fromInt(n)` type-checks. Sorted by method name for deterministic
1561    // emission (the resolver stores instance/static methods in `HashMap`s).
1562    //
1563    // P6.18: each method's own `params`/`return_type` now resolve to a real
1564    // `TyId` (`bynk_lower::lower_attached_fn_sig_ir_from_types`) against the
1565    // *declaring* unit's own visible types, rather than carrying the raw
1566    // `FnDecl` (and its unresolved `TypeRef`s) all the way to
1567    // `emit_forwarded_methods`. `Free`-named entries (never present in
1568    // practice — `ResolverMethodTable` only ever collects attached methods,
1569    // `bynk-check/src/resolver.rs:47`) are skipped, matching
1570    // `emit_forwarded_methods`'s own pre-existing `FnName::Method` filter
1571    // one step earlier rather than lowering a signature nothing renders.
1572    let mut imported_methods: HashMap<String, Vec<FnSig>> = HashMap::new();
1573    for t in &info.uses {
1574        let Some(used) = unit_info.get(t) else {
1575            continue;
1576        };
1577        let used_types = bynk_check::symbols::combined_types_for_unit_info(t, unit_info);
1578        for (type_name, mt) in &used.table.methods {
1579            let entry = imported_methods.entry(type_name.clone()).or_default();
1580            entry.extend(lower_attached_fn_sig_ir_from_types(mt, &used_types, tys));
1581        }
1582    }
1583    // #1807: a hidden type's methods are forwarded like a `uses` type's,
1584    // resolved in its owning commons' scope.
1585    for (type_name, owner) in hidden_types {
1586        let Some(mt) = unit_info
1587            .get(owner)
1588            .and_then(|o| o.table.methods.get(type_name))
1589        else {
1590            continue;
1591        };
1592        let owner_types = bynk_check::symbols::combined_types_for_unit_info(owner, unit_info);
1593        imported_methods
1594            .entry(type_name.clone())
1595            .or_default()
1596            .extend(lower_attached_fn_sig_ir_from_types(mt, &owner_types, tys));
1597    }
1598    for decls in imported_methods.values_mut() {
1599        decls.sort_by_key(|sig| sig.name.clone());
1600    }
1601    let mut imported_decl_paths: HashMap<String, HashMap<String, PathBuf>> = HashMap::new();
1602    for t in &info.uses {
1603        if let Some(target_info) = unit_info.get(t) {
1604            let target_index = &target_info.file_index;
1605            let mut paths: HashMap<String, PathBuf> = HashMap::new();
1606            for (n, p) in &target_index.types {
1607                paths.insert(n.clone(), p.clone());
1608            }
1609            for (n, p) in &target_index.fns {
1610                paths.insert(n.clone(), p.clone());
1611            }
1612            imported_decl_paths.insert(t.clone(), paths);
1613        }
1614    }
1615    for t in &info.consumes {
1616        if let Some(target_info) = unit_info.get(t) {
1617            let target_index = &target_info.file_index;
1618            let mut paths: HashMap<String, PathBuf> = HashMap::new();
1619            // Only expose exported names — the emitter needs to know
1620            // which file declares them so it can render the import.
1621            let exports_for_target = &target_info.exports;
1622            for n in exports_for_target.keys() {
1623                if let Some(p) = target_index.types.get(n) {
1624                    paths.insert(n.clone(), p.clone());
1625                }
1626            }
1627            imported_decl_paths.insert(t.clone(), paths);
1628        }
1629    }
1630    // #1807: a hidden type is imported from its owning commons, which this
1631    // unit need not `uses` itself.
1632    for (type_name, owner) in hidden_types {
1633        if let Some(p) = unit_info
1634            .get(owner)
1635            .and_then(|o| o.file_index.types.get(type_name))
1636        {
1637            imported_decl_paths
1638                .entry(owner.clone())
1639                .or_default()
1640                .insert(type_name.clone(), p.clone());
1641        }
1642    }
1643
1644    let exports_for_consumed = info
1645        .consumes
1646        .iter()
1647        .map(|t| {
1648            (
1649                t.clone(),
1650                unit_info
1651                    .get(t)
1652                    .map(|i| i.exports.clone())
1653                    .unwrap_or_default(),
1654            )
1655        })
1656        .collect();
1657
1658    // In workers mode, rewrite imported_decl_paths for consumed
1659    // contexts to point at the consumed Worker's handlers.ts.
1660    let mut imported_decl_paths_emit = imported_decl_paths.clone();
1661    if matches!(target, BuildTarget::Workers) {
1662        for (unit, decls) in imported_decl_paths.iter() {
1663            let target_kind = unit_info.get(unit).map(|i| i.kind);
1664            if target_kind == Some(UnitKind::Context) {
1665                let handlers_path = worker_handlers_source_path(unit);
1666                let mut rewritten = HashMap::new();
1667                for n in decls.keys() {
1668                    rewritten.insert(n.clone(), handlers_path.clone());
1669                }
1670                imported_decl_paths_emit.insert(unit.clone(), rewritten);
1671            }
1672        }
1673    }
1674
1675    let mut file_decl_index = info.file_index.clone();
1676    if split_worker {
1677        let module = |p: &mut PathBuf| *p = worker_file_source_path(name, p);
1678        file_decl_index.types.values_mut().for_each(module);
1679        file_decl_index.fns.values_mut().for_each(module);
1680        file_decl_index.agents.values_mut().for_each(module);
1681        file_decl_index.capabilities.values_mut().for_each(module);
1682        file_decl_index
1683            .methods
1684            .values_mut()
1685            .flat_map(HashMap::values_mut)
1686            .for_each(module);
1687    }
1688
1689    EmitUnitCtx {
1690        imported_methods,
1691        imported_decl_paths_emit,
1692        exports_for_consumed,
1693        file_decl_index,
1694        split_worker,
1695    }
1696}
1697
1698#[allow(clippy::too_many_arguments)]
1699fn emit_unit(
1700    name: &str,
1701    kind: UnitKind,
1702    pf: &ParsedFile,
1703    unit_ctx: &EmitUnitCtx,
1704    history_target_agents: &HashSet<String>,
1705    asset_served: &BTreeMap<String, String>,
1706    embed_text: &BTreeMap<String, String>,
1707    unit_info: &BTreeMap<String, UnitInfo>,
1708    imported_from: &HashMap<String, String>,
1709    imported_from_kind: &HashMap<String, UnitKind>,
1710    owning_context_for_emit: &Option<String>,
1711    cross_context_for_file: &resolver::CrossContextInfo,
1712    program: &checker::CheckedProgram,
1713    target: BuildTarget,
1714    import_ext: ImportExt,
1715    contracts: bool,
1716    agent_deps_plan: Option<&AgentDepsPlan>,
1717    compiled: &mut Vec<StagedFile>,
1718    schema_effective_versions: &SchemaVersions,
1719) {
1720    let typed = program.program();
1721    // Build the emitter context.
1722    let info = &unit_info[name];
1723    let mut cross_context_info = cross_context_for_file.clone();
1724    // #1932: the names the consumer gives a consumed context's unexported
1725    // types' codecs, for the call sites, from the same function its codec
1726    // generator uses.
1727    for c in cross_context_info.consumed_contexts.clone() {
1728        if let Some(types) = cross_context_info.consumed_types.get(&c) {
1729            let renames = emitter::unexported_type_renames(
1730                &c,
1731                types,
1732                unit_ctx.exports_for_consumed.get(&c),
1733                &typed.types,
1734                imported_from_kind,
1735            );
1736            cross_context_info.consumed_type_renames.insert(c, renames);
1737        }
1738    }
1739
1740    // v0.8: in workers mode, a context's *output* lands under
1741    // workers/<dashes>/handlers.ts. Use that path as the synthetic
1742    // source_path so the emitter's depth/relative-path logic and
1743    // imported_decl_paths produce correct relative imports.
1744    let workers_mode = matches!(target, BuildTarget::Workers);
1745    let emit_source_path = if unit_ctx.split_worker {
1746        worker_file_source_path(name, &pf.source_path())
1747    } else if workers_mode && kind == UnitKind::Context {
1748        worker_handlers_source_path(name)
1749    } else {
1750        pf.source_path()
1751    };
1752
1753    // message-bundles slice 1 (#859): a `messages` block's generated `render`
1754    // needs `bynk.locale`'s own `render` in scope for its fallback rung, but
1755    // under a private alias — this file's own `export function render` would
1756    // otherwise collide with a plain `import { render }`. Injected as a hand-
1757    // written extra import line rather than through the usual reference-
1758    // collection path (`collect_external_references`/`record_name_ref`),
1759    // which has no per-name aliasing of its own and would emit a colliding,
1760    // unaliased `render`.
1761    // #1820: in a split context, only a file declaring one of the plan's
1762    // agents builds its deps, so only that file imports what they need.
1763    let mut extra_import_lines: Vec<String> = agent_deps_plan
1764        .filter(|p| {
1765            !unit_ctx.split_worker
1766                || p.exprs.keys().any(|agent| {
1767                    unit_ctx.file_decl_index.agents.get(agent) == Some(&emit_source_path)
1768                })
1769        })
1770        .map(|p| p.imports.clone())
1771        .unwrap_or_default();
1772    if pf.declares_messages() {
1773        let render_path = unit_ctx
1774            .imported_decl_paths_emit
1775            .get("bynk.locale")
1776            .and_then(|m| m.get("render"))
1777            .cloned()
1778            .unwrap_or_else(|| EmitProjectCtx::commons_path("bynk.locale"));
1779        let import = emitter::cross_commons_import_specifier_for_path(
1780            &emit_source_path,
1781            &render_path,
1782            import_ext,
1783        );
1784        // #1697: the generated table and `render` name the `bynk.locale.types`
1785        // types under private aliases too, so a user's own `Message` or
1786        // `LocaleTag` in this unit (local shadows `uses`) can't capture them.
1787        let types_path = unit_ctx
1788            .imported_decl_paths_emit
1789            .get("bynk.locale.types")
1790            .and_then(|m| m.get("LocaleTag"))
1791            .cloned()
1792            .unwrap_or_else(|| EmitProjectCtx::commons_path("bynk.locale.types"));
1793        let types_import = emitter::cross_commons_import_specifier_for_path(
1794            &emit_source_path,
1795            &types_path,
1796            import_ext,
1797        );
1798        extra_import_lines.push(format!(
1799            "import {{ render as __bynkLocaleRender, renderArg as __bynkRenderArg }} from \"{import}\";\n\
1800             import type {{ LocaleTag as __bynkLocaleTag, Message as __bynkMessage, MessageArg as __bynkMessageArg }} from \"{types_import}\";"
1801        ));
1802    }
1803
1804    let agent_called_contexts: Vec<String> =
1805        emitter::agent_cross_context_targets(&info.table, |c| cross_context_info.resolve_prefix(c))
1806            .into_iter()
1807            .collect();
1808    let emit_ctx = EmitProjectCtx {
1809        source_path: emit_source_path,
1810        identity_path: pf.identity_path().to_string_lossy().into_owned(),
1811        commons_name: name.to_string(),
1812        file_decl_index: unit_ctx.file_decl_index.clone(),
1813        imported_from: imported_from.clone(),
1814        imported_from_kind: imported_from_kind.clone(),
1815        imported_decl_paths: unit_ctx.imported_decl_paths_emit.clone(),
1816        unit_kind: kind,
1817        owning_context: owning_context_for_emit.clone(),
1818        exports_for_consumed: unit_ctx.exports_for_consumed.clone(),
1819        imported_methods: unit_ctx.imported_methods.clone(),
1820        cross_context: cross_context_info,
1821        target,
1822        local_agents: info.table.agents.keys().cloned().collect(),
1823        agent_given_deps: agent_deps_plan.map(|p| p.exprs.clone()).unwrap_or_default(),
1824        extra_import_lines,
1825        agent_method_givens: info
1826            .table
1827            .agents
1828            .iter()
1829            .map(|(agent, a)| {
1830                (
1831                    agent.clone(),
1832                    a.handlers
1833                        .iter()
1834                        .filter_map(|h| {
1835                            h.method_name
1836                                .as_ref()
1837                                .map(|m| (m.name.clone(), lower_handler_given_ir(h)))
1838                        })
1839                        .collect(),
1840                )
1841            })
1842            .collect(),
1843        agent_called_contexts,
1844        // v0.47: the context's actors (merged across files), so the Bearer
1845        // verification seam resolves even when the actor and handler are in
1846        // different files of the same context.
1847        actors: info.table.actors.clone(),
1848        // Events slice 3b (#978), verified by slice 3c (#980): resolved once
1849        // per unit, merged across files the same way `actors` is above. The
1850        // registry's reconciled version wins when present (it is the
1851        // auto-bumped or `@schema(N)`-verified truth); `decl.schema_version()`
1852        // is the fallback for when the registry is off, matching every
1853        // event's pre-3c behaviour exactly.
1854        agent_schema_versions: info
1855            .table
1856            .agents
1857            .keys()
1858            .filter_map(|agent_name| {
1859                schema_effective_versions
1860                    .agents
1861                    .get(&format!("{name}.{agent_name}"))
1862                    .map(|v| (agent_name.clone(), v.clone()))
1863            })
1864            .collect(),
1865        event_schema_versions: info
1866            .table
1867            .events
1868            .iter()
1869            .map(|(event_name, decl)| {
1870                let key = format!("{name}.{event_name}");
1871                let version = schema_effective_versions
1872                    .events
1873                    .get(&key)
1874                    .copied()
1875                    .unwrap_or_else(|| decl.schema_version());
1876                (event_name.clone(), version)
1877            })
1878            .collect(),
1879        consumed_adapters: info
1880            .consumes
1881            .iter()
1882            .filter(|t| unit_info.get(*t).map(|i| i.kind) == Some(UnitKind::Adapter))
1883            .cloned()
1884            .collect(),
1885        consumed_types_with_methods: info
1886            .consumes
1887            .iter()
1888            .filter_map(|t| unit_info.get(t))
1889            .filter(|i| i.kind == UnitKind::Context)
1890            .flat_map(|i| {
1891                i.table
1892                    .methods
1893                    .iter()
1894                    .filter(|(_, m)| !m.instance.is_empty() || !m.statics.is_empty())
1895                    .map(|(n, _)| n.clone())
1896            })
1897            .collect(),
1898        import_ext,
1899        contracts,
1900        history_target_agents: history_target_agents.clone(),
1901        asset_served: asset_served.clone(),
1902        embed_text: embed_text.clone(),
1903        runtime_use: Default::default(),
1904    };
1905    // v0.72: the map's `source` is the absolute path the compiler read the file
1906    // from, so an editor breakpoint set on the real `.bynk` resolves to the same
1907    // path the debugger loads (project-relative would resolve against the output
1908    // `.ts`'s directory — the wrong place). Synthetic units fall back to relative.
1909    let source_name = pf.map_source_name();
1910    // #1486: `emit_project` now returns the real `TsProgram` (printed once
1911    // at the write boundary via `Document::text`) plus the source map
1912    // `bynk_ts::print` already computed while building it — no more
1913    // `Verbatim`-wrapping a pre-rendered string.
1914    // #1927: a `uses`-imported type this unit hides behind its own value is
1915    // spelled as an inline import type while the module is emitted
1916    // (`emitter::type_renames`).
1917    let hidden_type_spellings: HashMap<String, String> =
1918        bynk_check::project_model::shadowed_imports(name, unit_info)
1919            .into_iter()
1920            .filter(|(_, s)| s.imported == "type")
1921            .map(|(type_name, s)| {
1922                let path = unit_ctx
1923                    .imported_decl_paths_emit
1924                    .get(&s.unit)
1925                    .and_then(|m| m.get(&type_name))
1926                    .cloned()
1927                    .unwrap_or_else(|| EmitProjectCtx::commons_path(&s.unit));
1928                let spec = emitter::cross_commons_import_specifier_for_path(
1929                    &emit_ctx.source_path,
1930                    &path,
1931                    import_ext,
1932                );
1933                let spelling = format!("import(\"{spec}\").{type_name}");
1934                (type_name, spelling)
1935            })
1936            .collect();
1937    let _spellings = emitter::type_renames::enter(hidden_type_spellings);
1938    let (program, source_map) =
1939        emitter::emit_project(program, &emit_ctx, pf.source(), &source_name);
1940    // Slice 3: the handler-label sidecar for this unit (ADR 0105) — names stack
1941    // frames by their Bynk operation. `None` for units with no handlers.
1942    let debug_metadata = emitter::collect_handler_labels(typed);
1943    let output_path = if unit_ctx.split_worker {
1944        ts_output_path(&worker_file_source_path(name, &pf.source_path()))
1945    } else if workers_mode && kind == UnitKind::Context {
1946        worker_handlers_output_path(name)
1947    } else {
1948        ts_output_path(&pf.source_path())
1949    };
1950    compiled.push(StagedFile {
1951        output_path,
1952        document: Document::Ts(program),
1953        source_map,
1954        debug_metadata,
1955    });
1956}
1957
1958/// Phase 8d/8e: resolve + check (and, in build mode, emit) every source file in
1959/// one production unit. The per-file `continue`s stay internal to this loop, so
1960/// a file that fails resolution/checking is skipped without abandoning the unit.
1961///
1962/// P4.1 (#1115): the resolve+check+context-checks core — identical for both
1963/// `Mode`s except for the four `record_analyse_types` call sites — moved to
1964/// `bynk_check::check_pipeline::check_file_core` (see that module's own doc
1965/// comment), used by both this function and the new `bynk-check`-native
1966/// analysis entry point. This function now owns only: the per-unit
1967/// `EmitUnitCtx`/`prepare_unit_check_ctx` prelude, the `Mode`-conditional
1968/// exit (`Mode::Analyse` records and stops; `Mode::Build` proceeds to
1969/// `certify`+`emit_unit`) and the emission tail itself.
1970#[allow(clippy::too_many_arguments)]
1971#[allow(clippy::type_complexity)]
1972fn check_unit_files(
1973    name: &str,
1974    kind: UnitKind,
1975    // #1710: each unit's declarations recovery skipped (`phase_parse`).
1976    broken: &project_model::BrokenDeclNames,
1977    indices: &[usize],
1978    parsed: &[ParsedFile],
1979    unit_info: &BTreeMap<String, UnitInfo>,
1980    combined_types: &HashMap<String, Arc<TypeDecl>>,
1981    combined_fns: &HashMap<String, Arc<FnDecl>>,
1982    combined_methods: &HashMap<String, ResolverMethodTable>,
1983    local_names: &HashSet<String>,
1984    local_methods_for_type: &HashMap<String, Vec<FnDecl>>,
1985    consumed_types: &HashMap<String, ConsumedType>,
1986    imported_from: &HashMap<String, String>,
1987    imported_from_kind: &HashMap<String, UnitKind>,
1988    // #1807: the types this unit reaches through imported declarations
1989    // (#1824: and the shadows among them).
1990    reached_types: &project_model::ReachedTypes,
1991    owning_context_for_emit: &Option<String>,
1992    target: BuildTarget,
1993    import_ext: ImportExt,
1994    contracts: bool,
1995    agent_deps_plan: Option<&AgentDepsPlan>,
1996    history_target_agents: &HashSet<String>,
1997    asset_served: &BTreeMap<String, String>,
1998    embed_text: &BTreeMap<String, String>,
1999    mode: Mode,
2000    policy: &Arc<bynk_check::stability::StabilityPolicy>,
2001    errors: &mut ErrorSink,
2002    refs: &mut RefSink,
2003    hints: &mut HintSink,
2004    locals: &mut LocalsSink,
2005    exprs: &mut ExprTypeSink,
2006    requirements: &mut RequirementSink,
2007    compiled: &mut Vec<StagedFile>,
2008    // Events track, slice 3c (#980): each locally-declared event's *effective*
2009    // schema version, keyed `<unit>.<EventName>` — the schema registry's
2010    // reconciled value when the registry is on, empty (so every lookup falls
2011    // through to `EventDecl::schema_version()`) when it is off.
2012    schema_effective_versions: &SchemaVersions,
2013    tys: &Arc<Types>,
2014    // #1187's slice 6 plumbing — this unit's own accumulator; merged into
2015    // per file below, from each file's own certified `CheckedProgram`
2016    // (`RunChecks::Checked::unit_callees`'s own doc comment has the full
2017    // grounding for why this exists).
2018    unit_callees: &mut HashMap<ExprId, bynk_check::checker::Callee>,
2019    // P6.x (#1232): this unit's own declared event-subscriber service
2020    // shapes, keyed by service name — see `EventSubscriberShape`'s own doc
2021    // comment. Populated the same way as `unit_callees` above: merged from
2022    // each file's own `CheckedProgram` before it is dropped at the end of
2023    // this loop's iteration.
2024    event_subscriber_shapes: &mut HashMap<String, EventSubscriberShape>,
2025) {
2026    // Emit-prologue tables invariant across every file of this unit — built
2027    // once here rather than once per file (see `EmitUnitCtx`).
2028    let split_worker =
2029        target == BuildTarget::Workers && kind == UnitKind::Context && indices.len() > 1;
2030    let unit_ctx = build_emit_unit_ctx(
2031        name,
2032        unit_info,
2033        &reached_types.hidden,
2034        target,
2035        tys,
2036        split_worker,
2037    );
2038    let first_emitted = compiled.len();
2039    let check_ctx = prepare_unit_check_ctx(
2040        name,
2041        kind,
2042        broken,
2043        unit_info,
2044        combined_types,
2045        imported_from_kind,
2046        reached_types,
2047        policy,
2048    );
2049
2050    for &i in indices {
2051        let pf = &parsed[i];
2052        let Some(check_pipeline::FileCheckResult {
2053            typed,
2054            cross_context: cross_context_for_file,
2055        }) = check_pipeline::check_file_core(
2056            name,
2057            kind,
2058            pf,
2059            unit_info,
2060            combined_types,
2061            combined_fns,
2062            combined_methods,
2063            local_names,
2064            local_methods_for_type,
2065            consumed_types,
2066            imported_from,
2067            &check_ctx,
2068            errors,
2069            refs,
2070            hints,
2071            locals,
2072            exprs,
2073            requirements,
2074            tys,
2075        )
2076        else {
2077            continue;
2078        };
2079
2080        // Analyse mode stops at checked: emission is build-only. Capture the
2081        // file's expression types on the way out (Ok path only — this point is
2082        // past every per-file error exit inside `check_file_core`), for
2083        // `.`-member completion.
2084        if mode == Mode::Analyse {
2085            check_pipeline::record_analyse_types(
2086                exprs,
2087                &pf.identity_path(),
2088                pf.is_synthetic(),
2089                &typed.expr_types,
2090            );
2091            continue;
2092        }
2093        // T3.7b (R3.10): every per-unit gate above already ran (check_record's
2094        // Ok path, check_context_constraints, check_context_declarations's
2095        // blocks_emission, all inside `check_file_core`) — certify makes that
2096        // structural, the same way T3.7a did for the single-file path, rather
2097        // than relying on every future call site remembering to check all
2098        // three before reaching emission. A later, unrelated diagnostic (e.g.
2099        // check_platform_lock, which runs after this whole loop) can still
2100        // bail the entire build via finish_build's separate
2101        // errors.is_empty() gate — that's whole-build atomicity (already
2102        // correct, already unconditional), orthogonal to this unit's own
2103        // certification here.
2104        let program = checker::certify(typed, Vec::new()).unwrap_or_else(|_| {
2105            panic!("bynk internal error: unit already passed every per-unit gate above")
2106        });
2107        // #1187's slice 6 plumbing: merge this file's own resolved `Callee`
2108        // classification into the unit's accumulator before `program` (and
2109        // the `TypedCommons` it wraps) is dropped at the end of this
2110        // iteration — the only point in this pipeline that ever holds it.
2111        // Filtered to the two variants either reader actually matches on
2112        // (review of #1202): every other `Callee` variant would otherwise
2113        // sit retained project-wide, for the rest of the build, to answer
2114        // two boolean-ish questions — a real `String`/`Arc` cost on a large
2115        // project with nothing reading the rest yet. Widen this filter (or
2116        // drop it) the moment a future reader needs a different variant.
2117        unit_callees.extend(program.program().callees.iter().filter_map(|(id, c)| {
2118            let keep = match c {
2119                bynk_check::checker::Callee::Cross { .. } => true,
2120                bynk_check::checker::Callee::Capability { cap, op } => {
2121                    cap == "Events" && op == "emit"
2122                }
2123                _ => false,
2124            };
2125            keep.then(|| (*id, c.clone()))
2126        }));
2127        // P6.23 (review of #1254): captures this file's own
2128        // event-subscriber service shapes the same way as before —
2129        // before `program` is dropped at the end of this iteration, same
2130        // insertion point as `unit_callees.extend` above. P6.47 (#1137):
2131        // the walk itself (including the `ServiceProtocol::Events`
2132        // pre-filter guarding `lower_service_item_ir` — see that
2133        // function's own doc comment for why the guard stays) relocated to
2134        // `bynk_lower::lower_event_subscriber_shapes_ir`, an excluded file.
2135        event_subscriber_shapes.extend(bynk_lower::lower_event_subscriber_shapes_ir(&program));
2136        emit_unit(
2137            name,
2138            kind,
2139            pf,
2140            &unit_ctx,
2141            history_target_agents,
2142            asset_served,
2143            embed_text,
2144            unit_info,
2145            imported_from,
2146            imported_from_kind,
2147            owning_context_for_emit,
2148            &cross_context_for_file,
2149            &program,
2150            target,
2151            import_ext,
2152            contracts,
2153            agent_deps_plan,
2154            compiled,
2155            schema_effective_versions,
2156        );
2157    }
2158    if mode == Mode::Build && split_worker {
2159        let barrel = emit_worker_handlers_barrel(
2160            name,
2161            indices,
2162            parsed,
2163            &compiled[first_emitted..],
2164            import_ext,
2165        );
2166        compiled.push(barrel);
2167    }
2168}
2169
2170/// #1820: `handlers.ts` for a context split across files, on Workers. It
2171/// re-exports each file's module, so the Worker's entry point, its composition
2172/// root and every consuming context see the context as one module, as they do
2173/// a single-file context's `handlers.ts`. Like a multi-file unit's test barrel
2174/// (`emit_commons_barrel`), it also defines the merged helpers each file
2175/// generates for itself (`merged_unit_helpers`), which `export *` alone
2176/// would leave ambiguous: only `__resetAgents`, since a Worker's files export
2177/// no surface.
2178///
2179/// Each file also emits its own copy of what its declarations need from
2180/// outside it (a boundary type's codecs, a `uses`d commons type's rebrand), so
2181/// two files can both export one name; the barrel re-exports each such name
2182/// from one module of `emitted`, this unit's (`duplicated_export_reexports`).
2183fn emit_worker_handlers_barrel(
2184    name: &str,
2185    indices: &[usize],
2186    parsed: &[ParsedFile],
2187    emitted: &[StagedFile],
2188    import_ext: ImportExt,
2189) -> StagedFile {
2190    let barrel_loc = worker_handlers_source_path(name);
2191    let module_of = |file: &Path| worker_file_source_path(name, file);
2192    let mut modules: Vec<PathBuf> = indices
2193        .iter()
2194        .map(|&i| module_of(&parsed[i].source_path()))
2195        .collect();
2196    modules.sort();
2197    modules.dedup();
2198    let mut stmts = vec![TsStmt::comment(
2199        "Generated by bynkc — do not edit by hand.",
2200        None,
2201    )];
2202    for module in &modules {
2203        let spec =
2204            emitter::cross_commons_import_specifier_for_path(&barrel_loc, module, import_ext);
2205        stmts.push(TsStmt::decl(TsDecl::ReExportAll { from: spec }, None));
2206    }
2207    let (merged, defined) = merged_unit_helpers(
2208        name,
2209        indices,
2210        parsed,
2211        &barrel_loc,
2212        import_ext,
2213        module_of,
2214        false,
2215    );
2216    stmts.extend(duplicated_export_reexports(
2217        &modules,
2218        emitted,
2219        &defined,
2220        &barrel_loc,
2221        import_ext,
2222    ));
2223    stmts.extend(merged);
2224    StagedFile {
2225        output_path: worker_handlers_output_path(name),
2226        document: Document::Ts(TsProgram { stmts }),
2227        source_map: None,
2228        debug_metadata: None,
2229    }
2230}
2231
2232/// The outcome of the shared check pipeline (regions 1+2's shared work),
2233/// before whichever caller applies its own divergent exit — `compile_project`/
2234/// `compile_in_memory` project it into a `Result<ProjectOutput, ProjectFailure>`
2235/// via `finish_build`; `check_project`/`analyse_in_memory_with_types` read
2236/// straight off it instead. #1541 deleted the fifth of these five callers
2237/// (`analyse_project_with`, which alone read `hints`/`requirements`/`refs`
2238/// back out) along with those three fields — they're still populated during
2239/// checking (the shared `bynk_check` phase functions this file calls need the
2240/// sinks regardless of who reads them after), just no longer carried out on
2241/// this enum.
2242#[allow(clippy::large_enum_variant)]
2243enum RunChecks {
2244    /// Discovery/parse failed, or (build mode) the structural gate bailed:
2245    /// only diagnostics, no checked program. Index is not assembled here.
2246    Bailed {
2247        errors: ErrorSink,
2248        snapshots: Vec<(PathBuf, String)>,
2249        locals: LocalsSink,
2250        exprs: ExprTypeSink,
2251    },
2252    /// All phases ran (per-unit checks + tests + platform-lock done).
2253    Checked {
2254        errors: ErrorSink,
2255        snapshots: Vec<(PathBuf, String)>,
2256        locals: LocalsSink,
2257        exprs: ExprTypeSink,
2258        parsed: Vec<ParsedFile>,
2259        compiled: Vec<StagedFile>,
2260        runnable_tests: Vec<RunnableTest>,
2261        integration_outputs: Vec<StagedFile>,
2262        integration_runnables: Vec<RunnableTest>,
2263        groups: BTreeMap<String, Vec<usize>>,
2264        kinds: BTreeMap<String, UnitKind>,
2265        unit_uses: HashMap<String, Vec<String>>,
2266        unit_consumes: HashMap<String, Vec<String>>,
2267        unit_consumes_aliases: HashMap<String, HashMap<String, String>>,
2268        unit_tables: HashMap<String, UnitTable>,
2269        // #2252: per context, the types it takes from a consumed context —
2270        // the names whose codecs `emit_boundary_helpers` emits under the same
2271        // rule — so the entry validates one through that codec.
2272        unit_consumed_context_types: HashMap<String, HashSet<String>>,
2273        // #1187's slice 6 plumbing: each unit's own `Callee` classification,
2274        // merged across its files (`ExprId` is a single project-wide
2275        // counter, `project_model.rs`'s `next_expr_id`, so merging different
2276        // files' maps never collides) — checked, resolved data the pre-check
2277        // `unit_tables` above cannot carry. Exists so a later, project-wide
2278        // pass (`build_output`/`emit_composition_root`) can read an
2279        // already-resolved `Callee::Capability`/`Callee::Cross` instead of
2280        // re-deriving the same fact by walking raw AST method-call syntax —
2281        // `check_unit_files`'s own per-file `CheckedProgram` was previously
2282        // built and dropped before any such later pass ever ran. Filtered at
2283        // merge time (`check_unit_files`'s own `unit_callees.extend` call,
2284        // review of #1202) to only `Callee::Cross` and
2285        // `Callee::Capability{cap:"Events",op:"emit"}` — the two variants
2286        // `unit_table_uses_emit`/`called_cross_context_services` actually
2287        // read today; widen the filter (or drop it) the moment a future
2288        // reader needs a different variant, rather than paying to retain
2289        // every call site's full classification project-wide for the rest
2290        // of the build on spec.
2291        unit_callees: HashMap<String, HashMap<ExprId, bynk_check::checker::Callee>>,
2292        // P6.x (#1232): each unit's own declared event-subscriber service
2293        // shapes, merged across its files — see `EventSubscriberShape`'s own
2294        // doc comment. Threaded the same way as `unit_callees` immediately
2295        // above.
2296        unit_event_subscriber_shapes: HashMap<String, HashMap<String, EventSubscriberShape>>,
2297        unit_flattened: HashMap<String, HashMap<String, String>>,
2298        adapter_bindings: HashMap<String, AdapterBinding>,
2299        npm_deps: std::collections::BTreeMap<String, String>,
2300        target: BuildTarget,
2301        // Events track, slice 3c (#980): the reconciled registry document,
2302        // ready for `finish_build` to serialize onto
2303        // `ProjectOutput::schema_lock`. `None` when `schema_registry` was
2304        // `SchemaLock::Off` (#1078) — nothing for a caller to persist.
2305        schema_registry: Option<schema_registry::SchemaRegistry>,
2306        // #1883, slice 1b: the bytes of each declared asset file, by
2307        // normalised path ([`bynk_check::assets::read_declared_files`]), for
2308        // the Workers' files. `None` when no folder was listed.
2309        asset_bytes: Option<BTreeMap<String, Vec<u8>>>,
2310        // State-migrations S4 (#2259): each agent class's reconciled versions
2311        // and valid steps, keyed `<unit>.<Agent>`, for `bynk migrate status`.
2312        // Empty when the build has no lock.
2313        agent_versions: HashMap<String, bynk_check::schema_registry::AgentVersions>,
2314    },
2315}
2316
2317#[allow(clippy::too_many_arguments)]
2318fn run_checks(
2319    // R3.9 (#1113): one `(root, prefix)` pair per `Roots::trees` entry, not a
2320    // hardcoded primary/secondary pair — every `include` tree is walked, not
2321    // just the first one or two.
2322    trees: &[(PathBuf, PathBuf)],
2323    target: BuildTarget,
2324    platform: Platform,
2325    import_ext: ImportExt,
2326    mode: Mode,
2327    overlay: &HashMap<PathBuf, String>,
2328    // v0.113: absolute subtrees to skip during discovery (author `exclude` plus
2329    // the tool's `out`/`node_modules` caches). Empty for in-memory builds.
2330    excludes: &[PathBuf],
2331    // v0.108 (in-browser track, slice 3): when `Some`, the source files are
2332    // supplied directly, one file list per `trees` entry — and filesystem
2333    // discovery is skipped. The wasm/REPL entry feeds an in-memory
2334    // single-module project this way (the source itself rides in `overlay`);
2335    // `None` keeps the on-disk discovery walk for the CLI and the LSP.
2336    discovered: Option<Vec<Vec<PathBuf>>>,
2337    // v0.115: emit the function-contract call-site guard (dev/test profile).
2338    contracts: bool,
2339    // #1821 (ADR 0147 D3): check and emit `suite`s. Off for a deployable build.
2340    tests: bool,
2341    // Events track, slice 3c (#980): `On` turns on `bynk.schema.lock`
2342    // reconciliation, with its pre-read content; `Off` (every in-memory/
2343    // test/LSP caller) skips it entirely. See `CompileOptions::schema_registry`
2344    // and `SchemaLock`. #1078: no disk access here — the caller pre-reads.
2345    schema_registry: &SchemaLock,
2346    // #2138: a remote test build (`CompileOptions::remote_tests`); `None`
2347    // for every other build.
2348    remote_tests: Option<&RemoteTests>,
2349    // #1085 review: only for `schema_registry::parse`'s corruption message —
2350    // naming *which* project's lock file is corrupt, now that #1078 made
2351    // `bynk-emit` disk-free (and so path-blind) for this file.
2352    project_root: &Path,
2353    // #1883, slice 1b: the listed `assets/` folder
2354    // (`CompileOptions::asset_files`). `None` when no filesystem was
2355    // consulted: a check then skips the existence check, a build treats it as
2356    // empty.
2357    asset_files: Option<&assets::AssetFolder>,
2358    // #2387, slice 2: the listed `embeds/` folder
2359    // (`CompileOptions::embed_files`), with the same `None` meaning.
2360    embed_files: Option<&assets::EmbedFolder>,
2361    // #2223: resolve `project_root`'s path dependencies and fold them in
2362    // (`bynk_check::packages::apply_graph`). Only a manifest-backed project
2363    // has any; an in-memory build never does.
2364    packages: bool,
2365    tys: &Arc<Types>,
2366) -> RunChecks {
2367    let mut errors = ErrorSink::new();
2368    // S3 (#1963): the project's `[unstable]` opt-in, read as the editor reads it
2369    // (`bynk_check::analysis::analyse_project`), so the two never disagree.
2370    let policy = Arc::new(bynk_check::stability::StabilityPolicy::for_project(
2371        Some(project_root),
2372        overlay,
2373    ));
2374    for err in policy.manifest_diagnostics() {
2375        errors.push_for(Some(Path::new(bynk_check::stability::MANIFEST)), err);
2376    }
2377    // v0.25 (ADR 0053): binding edges, recorded at the resolution sites — by
2378    // `check_file_core` and several `project_model::phase_*` resolution
2379    // calls below, all of which require this sink unconditionally.
2380    // `bynk_check::analysis::analyse_project` threads the same sinks through
2381    // its own call to those functions and does read its copy back out (into
2382    // `ProjectAnalysis.index`, via `assemble_index`). Here, #1541 deleted the
2383    // one caller (`analyse_project_with`) that drained this copy back out via
2384    // `RunChecks`, so it's populated and discarded — never surfaced by this
2385    // enum anymore.
2386    let mut refs = RefSink::new();
2387    // v0.27 (ADR 0056): inferred-type inlay hints, recorded at the checker's
2388    // binding sites. A sink (not part of the checker's Ok payload) so hints
2389    // survive the per-file error-`continue`s. Same #1541 residue as `refs`:
2390    // populated, never read back out of `RunChecks`.
2391    let mut hints = HintSink::new();
2392    let mut locals = LocalsSink::new();
2393    // v0.99: the capability-requirement ledger — recorded at the checker's
2394    // capability-consuming sites. Same #1541 residue as `refs`/`hints`.
2395    let mut requirements = RequirementSink::new();
2396    // v0.30.2 (ADR 0063): per-file expression types, so `.`-member completion
2397    // can type a receiver — on the error paths too, as best-effort partial
2398    // types (ADR 0094). Carried like `hints`.
2399    let mut exprs = ExprTypeSink::new();
2400    let mut snapshots: Vec<(PathBuf, String)> = Vec::new();
2401
2402    // -- 1. Discovery (skipped when sources are supplied in memory). --
2403    let file_lists = match discovered {
2404        Some(files) => files,
2405        None => match project_model::phase_discovery(trees, excludes, &mut errors) {
2406            Ok(files) => files,
2407            Err(()) => {
2408                return RunChecks::Bailed {
2409                    errors,
2410                    snapshots,
2411                    locals,
2412                    exprs,
2413                };
2414            }
2415        },
2416    };
2417    // #1077/#1081 review: `no_sources`/file-directory-conflict checks run on
2418    // every tree's file list regardless of provenance — see
2419    // `check_discovered_files`'s own doc.
2420    if project_model::check_discovered_files(trees, &file_lists, &mut errors).is_err() {
2421        return RunChecks::Bailed {
2422            errors,
2423            snapshots,
2424            locals,
2425            exprs,
2426        };
2427    }
2428
2429    // -- 2. Parse every file. --
2430    let (mut parsed, injected, broken) = match project_model::phase_parse(
2431        trees,
2432        &file_lists,
2433        overlay,
2434        &mut errors,
2435        &mut snapshots,
2436    ) {
2437        Ok(out) => out,
2438        Err(()) => {
2439            return RunChecks::Bailed {
2440                errors,
2441                snapshots,
2442                locals,
2443                exprs,
2444            };
2445        }
2446    };
2447    // -- 2a. #2223: fold the project's path dependencies in. --
2448    if packages {
2449        bynk_check::packages::apply_graph(
2450            Some(project_root),
2451            overlay,
2452            &mut parsed,
2453            tests,
2454            &mut errors,
2455        );
2456    }
2457
2458    // -- 2b. Normalize service-level `by`/`given` defaults (v0.155). A service
2459    //        header default is injected into every handler that omits its own
2460    //        clause, so every downstream phase (grouping, checking, validation,
2461    //        emission) reads canonical handlers with no special-casing. `parsed`
2462    //        is indexed (not cloned) by later phases, so mutating it here reaches
2463    //        them all. The parsed AST that `bynk fmt` produces is untouched (it
2464    //        parses independently), so the terse inheriting source round-trips.
2465    project_model::normalize_service_defaults(&mut parsed);
2466    // #1821 (ADR 0147 D3): a deployable build strips every suite, here, right
2467    // after parsing, so no later pass (the reserved-namespace and
2468    // function-type checks walk every parsed unit) sees one: never
2469    // type-checked for the build, never emitted. A file holding a unit and
2470    // its suite is two parsed units, so the unit stays. A file that does not
2471    // parse at all has already failed above: it cannot be told apart from
2472    // production source.
2473    if !tests {
2474        parsed.retain(|pf| !matches!(pf.kind(), UnitKind::Test | UnitKind::Integration));
2475    }
2476    let parsed = parsed;
2477
2478    // -- 3. Group by (name, kind) and validate per-directory consistency.
2479    //       P5.2 (`design/tracks/semantics-in-the-checker.md` §6):
2480    //       `phase_group` now also confines function types to non-boundary
2481    //       positions directly, at the point its old optional hook used to
2482    //       fire — see that function's own doc comment. --
2483    let (groups, kinds, test_groups, integration_groups, adapter_bindings, npm_deps) =
2484        project_model::phase_group(&parsed, trees, platform, &injected, overlay, &mut errors);
2485    // #2207 (packaging slice 1, decision D): `[package] exports` names only
2486    // commons and adapters this project declares — read as the editor reads it
2487    // (`bynk_check::analysis::analyse_project`).
2488    for err in bynk_check::exports::manifest_diagnostics(Some(project_root), overlay, &kinds) {
2489        errors.push_for(Some(Path::new(bynk_check::stability::MANIFEST)), err);
2490    }
2491    // -- 4. Build per-unit combined symbol tables. --
2492    let unit_tables = project_model::phase_symbol_tables(&groups, &kinds, &parsed, &mut errors);
2493
2494    // -- 5. Resolve `uses` clauses (target must exist + be a commons). --
2495    let unit_uses = project_model::phase_resolve_uses(
2496        &groups,
2497        &kinds,
2498        &parsed,
2499        &unit_tables,
2500        &policy,
2501        &mut errors,
2502    );
2503
2504    // -- 5b. Resolve `consumes` clauses (target must exist + be a context). --
2505    let (unit_consumes, unit_flattened) = project_model::phase_resolve_consumes(
2506        &groups,
2507        &kinds,
2508        &parsed,
2509        &unit_tables,
2510        &policy,
2511        &mut errors,
2512        &mut refs,
2513    );
2514    let mut unit_tables = unit_tables;
2515    bynk_check::symbols::record_flattened_caps(&mut unit_tables, &unit_flattened);
2516    bynk_check::project_model::phase_resolve_derived(
2517        &groups,
2518        &kinds,
2519        &parsed,
2520        &mut unit_tables,
2521        &mut errors,
2522    );
2523    let unit_tables = unit_tables;
2524
2525    // -- 5b'. Collect `consumes` aliases (v0.6 §3.1). Each consuming context
2526    //         has an alias map: alias → consumed-context qualified name.
2527    //         Detect alias-alias conflicts here; alias-vs-local-decl conflicts
2528    //         are checked once the local symbol tables are built (step 6+).
2529    let unit_consumes_aliases =
2530        project_model::phase_consumes_aliases(&groups, &kinds, &parsed, &unit_tables, &mut errors);
2531
2532    // -- 5b''. v0.173 (ADR 0196 D1): warn where a `bynk.Secrets` read names its
2533    //          secret with a computed expression. P5.5
2534    //          (`design/tracks/semantics-in-the-checker.md` §6, §9): relocated
2535    //          to `bynk_check::project_model::phase_secrets_computed_name` —
2536    //          this is now a caller, not an owner, the same move as this
2537    //          function's neighbours above. See that function's own doc for
2538    //          why raising it here no longer reaches the editor by itself.
2539    project_model::phase_secrets_computed_name(
2540        target,
2541        &parsed,
2542        &groups,
2543        &kinds,
2544        &unit_flattened,
2545        &mut errors,
2546    );
2547    // #1822: on Workers, a required local capability needs a provider.
2548    project_model::phase_workers_unprovided_capabilities(
2549        target,
2550        &parsed,
2551        &groups,
2552        &kinds,
2553        &unit_tables,
2554        &mut errors,
2555    );
2556    // #2306: on Workers, a WebSocket `on open` and the agent it transfers to
2557    // must share a file.
2558    project_model::phase_workers_ws_open_split(
2559        target,
2560        &parsed,
2561        &groups,
2562        &kinds,
2563        &unit_tables,
2564        &mut errors,
2565    );
2566    // #2355 follow-up: on Workers, a capability whose provider constructs
2567    // another context's agents cannot be built by this context's Worker.
2568    project_model::phase_workers_provider_agent_cross_context(
2569        target,
2570        &parsed,
2571        &groups,
2572        &kinds,
2573        &unit_tables,
2574        &unit_consumes,
2575        &unit_consumes_aliases,
2576        &unit_flattened,
2577        &mut errors,
2578    );
2579
2580    // -- 5c. Detect `consumes` cycles. --
2581    project_model::phase_detect_consumes_cycles(&groups, &parsed, &unit_consumes, &mut errors);
2582
2583    // -- 6. Name-conflict detection for uses imports (commons-only check). --
2584    project_model::phase_uses_name_conflicts(
2585        &unit_uses,
2586        &unit_tables,
2587        &parsed,
2588        &groups,
2589        &mut errors,
2590    );
2591
2592    // -- 6a0. #1883, slice 1b: every declared asset names a file in the
2593    //         `assets/` folder. A build with no folder read checks against an
2594    //         empty one; a check with none skips this. Then the declared files
2595    //         alone are read, through the folder's reader.
2596    let no_assets = BTreeSet::new();
2597    let available_assets: Option<&BTreeSet<String>> = match (asset_files, mode) {
2598        (Some(folder), _) => Some(folder.files()),
2599        (None, Mode::Build) => Some(&no_assets),
2600        (None, _) => None,
2601    };
2602    bynk_check::assets::phase_asset_files(&parsed, available_assets, &mut errors);
2603    let asset_bytes = asset_files
2604        .map(|folder| bynk_check::assets::read_declared_files(&parsed, folder, &mut errors));
2605    // -- 6a0'. #2387, slice 2: likewise every declared embed names a file in
2606    //          `embeds/`, then the declared files alone are read, as text.
2607    let no_embeds = BTreeSet::new();
2608    let available_embeds: Option<&BTreeSet<String>> = match (embed_files, mode) {
2609        (Some(folder), _) => Some(folder.files()),
2610        (None, Mode::Build) => Some(&no_embeds),
2611        (None, _) => None,
2612    };
2613    bynk_check::embeds::phase_embed_files(&parsed, available_embeds, &mut errors);
2614    let embed_text = embed_files
2615        .map(|folder| bynk_check::embeds::read_declared_embeds(&parsed, folder, &mut errors))
2616        .unwrap_or_default();
2617    // #1883, slice 2: fixed asset paths against each Worker's routes.
2618    bynk_check::assets::phase_asset_routes(&parsed, &groups, &kinds, &unit_uses, &mut errors);
2619    // A deployable Bundle build (no tests: `bynkc compile` without
2620    // `--target workers`) ships no files, so each asset's `.url` would 404:
2621    // say so. `bynkc test` also builds a Bundle, with tests, and stays quiet.
2622    if mode == Mode::Build && target == BuildTarget::Bundle && !tests {
2623        for pf in &parsed {
2624            for warning in bynk_check::assets::bundle_not_served_warnings(pf.items()) {
2625                errors.push_for(Some(&pf.identity_path()), warning);
2626            }
2627        }
2628    }
2629
2630    // -- 6a'. message-bundles slice 1 (#859): messages-block legality,
2631    //         @reference cardinality, within-block duplicate codes, and the
2632    //         `uses bynk.locale` dependency. Runs here (not in phase_group)
2633    //         because it needs `unit_uses`, resolved just above.
2634    //
2635    //         P5.0 (#1128, `design/tracks/semantics-in-the-checker.md` §6):
2636    //         relocated to `bynk-check::project_model` alongside the rest of
2637    //         this pipeline (P4.1's own move) — this is now a caller, not an
2638    //         owner, the same way P4.0/P4.1 turned this function into a
2639    //         caller of `bynk-project`/`bynk-check`.
2640    project_model::phase_messages_bundles(&parsed, &groups, &kinds, &unit_uses, &mut errors);
2641
2642    // -- 6a''. Locale capability track, slice 2 (#882): a context reaching
2643    //          two or more message-bundle commons while consuming `Locale`
2644    //          has no single bundle to negotiate against. P5.0: relocated,
2645    //          see above.
2646    project_model::phase_locale_bundle_ambiguity(
2647        &parsed,
2648        &groups,
2649        &kinds,
2650        &unit_uses,
2651        &unit_flattened,
2652        &mut errors,
2653    );
2654
2655    // -- 6a'''. Events track, slice 0 (spine #936): a `from Events(E)`
2656    //           subscription must name a real, declared event — needs
2657    //           `unit_tables` + `unit_consumes` together, so it runs here
2658    //           rather than in the per-context `check_service_protocols`.
2659    //
2660    //           P5.1 (#1130, `design/tracks/semantics-in-the-checker.md` §6):
2661    //           relocated to `bynk-check::project_model`, same move as
2662    //           P5.0's neighbours above.
2663    project_model::phase_event_subscriptions(
2664        &parsed,
2665        &groups,
2666        &kinds,
2667        &unit_tables,
2668        &unit_consumes,
2669        &unit_uses,
2670        &mut errors,
2671    );
2672
2673    // -- 6b. Validate exports clauses (each name is a locally-declared type;
2674    //         no duplicates within or across opaque/transparent). --
2675    let exports_visibility = project_model::phase_validate_type_exports(
2676        &groups,
2677        &kinds,
2678        &parsed,
2679        &unit_tables,
2680        &mut errors,
2681        &mut refs,
2682    );
2683
2684    // -- 6b'. Validate `exports capability { … }` clauses (v0.15 §4.1): each
2685    //          name must be a capability the context declares *and* provides. --
2686    project_model::phase_validate_capability_exports(
2687        &groups,
2688        &kinds,
2689        &parsed,
2690        &unit_tables,
2691        &mut errors,
2692        &mut refs,
2693    );
2694
2695    // -- 6c. Validate that providers match their capabilities exactly. --
2696    project_model::phase_validate_providers(
2697        &unit_tables,
2698        &groups,
2699        &parsed,
2700        &broken,
2701        &mut errors,
2702        tys,
2703    );
2704
2705    // -- 6d. Events track, slice 3c (#980): reconcile every event's shape
2706    //        against the committed schema registry. `schema_registry` is
2707    //        `SchemaLock::Off` for every in-memory/test/LSP/fixture caller
2708    //        (opt-in — see `CompileOptions::schema_registry`'s doc), in which
2709    //        case this is a no-op and every event falls back to today's
2710    //        `@schema(N)`-or-`1` behaviour. Must run before the per-unit loop
2711    //        below: `emit_unit` needs `schema_effective_versions` to mint the
2712    //        right `schemaVersion`, and by the time `RunChecks` reaches
2713    //        `finish_build` the TypeScript is already emitted. Only the
2714    //        *write* is deferred — to the caller, gated on a fully clean
2715    //        build (#1078: `bynk-emit` computes, never writes) — reconciliation
2716    //        itself happens here. P5.3: `reconcile` itself now lives in
2717    //        `bynk_check::schema_registry` (this crate is a caller, not an
2718    //        owner) — `SchemaRegistry` stays re-exported from this crate's
2719    //        own `schema_registry` module. P5.5: the corrupt-file diagnostic
2720    //        moved too — `bynk_check::schema_registry::parse_or_diagnose` is
2721    //        now this crate's caller-side of both the parse and the
2722    //        `bynk.project.schema_registry_corrupt` construction (§3.2's
2723    //        "eighth site").
2724    let mut schema_effective_versions = SchemaVersions::default();
2725    let mut schema_registry_doc: Option<schema_registry::SchemaRegistry> = None;
2726    if let SchemaLock::On { existing } = schema_registry {
2727        match bynk_check::schema_registry::parse_or_diagnose(existing.as_deref(), project_root) {
2728            Ok(existing_reg) => {
2729                let mut schema_errors: Vec<CompileError> = Vec::new();
2730                let (mut updated, effective) = bynk_check::schema_registry::reconcile(
2731                    &existing_reg,
2732                    &unit_tables,
2733                    &mut schema_errors,
2734                );
2735                errors.extend_for(None, schema_errors);
2736                // State-migrations S0 (#2204): agent state shapes, in the same
2737                // document. Attributed by span, so a refused change lands on
2738                // the agent's own file.
2739                let mut agent_errors: Vec<CompileError> = Vec::new();
2740                schema_effective_versions.agents = bynk_check::schema_registry::reconcile_agents(
2741                    &existing_reg,
2742                    &mut updated,
2743                    &unit_tables,
2744                    &unit_uses,
2745                    &mut agent_errors,
2746                );
2747                errors.extend_attributed_by_span(&parsed, agent_errors);
2748                schema_effective_versions.events = effective;
2749                schema_registry_doc = Some(updated);
2750            }
2751            Err(err) => {
2752                errors.push_for(None, err);
2753            }
2754        }
2755    }
2756
2757    if !errors.is_empty() && mode == Mode::Build {
2758        return RunChecks::Bailed {
2759            errors,
2760            snapshots,
2761            locals,
2762            exprs,
2763        };
2764    }
2765
2766    // -- 7. Build per-unit file index (which file declares which name). --
2767    let unit_file_index = project_model::phase_file_index(&groups, &parsed);
2768
2769    // -- 7b (v0.29.4). Assemble the nine parallel per-unit maps into one record
2770    //          per unit. Driven by the `groups` keyset (the authority), so every
2771    //          group yields exactly one `UnitInfo` with all facets present. The
2772    //          producer maps are cloned, not moved, because the back half of the
2773    //          pipeline (tests, integration tests, platform-lock, composition
2774    //          root, the workers branch) still reads the originals.
2775    let unit_info = project_model::assemble_unit_info(
2776        &groups,
2777        &kinds,
2778        &unit_tables,
2779        &unit_uses,
2780        &unit_consumes,
2781        &unit_flattened,
2782        &unit_consumes_aliases,
2783        &exports_visibility,
2784        &unit_file_index,
2785    );
2786
2787    // -- 8. For each unit, build the combined symbol space and run
2788    //       resolve+check per source file. --
2789    let mut compiled: Vec<StagedFile> = Vec::new();
2790    // #1187's slice 6 plumbing (see `RunChecks::Checked::unit_callees`'s own
2791    // doc comment) — one `Callee` map per unit, merged across that unit's
2792    // own files inside the loop below.
2793    let mut unit_callees: HashMap<String, HashMap<ExprId, bynk_check::checker::Callee>> =
2794        HashMap::new();
2795    // P6.x (#1232): one `EventSubscriberShape` map per unit, merged across
2796    // that unit's own files inside the loop below — same shape as
2797    // `unit_callees` immediately above.
2798    let mut unit_event_subscriber_shapes: HashMap<String, HashMap<String, EventSubscriberShape>> =
2799        HashMap::new();
2800    let mut unit_consumed_context_types: HashMap<String, HashSet<String>> = HashMap::new();
2801
2802    // v0.119 (testing track slice 7, ADR 0155): a project-wide fold over every
2803    // parsed file, producing the identical `HashSet` regardless of which unit
2804    // or file is currently emitting — computed once here rather than once per
2805    // emitted file (`collect_history_target_agents` used to be called from
2806    // inside the per-file emit prologue).
2807    let history_target_agents = collect_history_target_agents(&parsed);
2808    // #1883, slice 1b: each declared asset's served URL path, by normalised
2809    // source path — what its `.url` constant holds. Empty when no folder was
2810    // read (a missing file is already `bynk.asset.missing_file`).
2811    let asset_served = asset_bytes
2812        .as_ref()
2813        .map(|files| bynk_check::assets::declared_served_paths(&parsed, files))
2814        .unwrap_or_default();
2815
2816    // #1702 review: `uses` targets first, so a generic callee's compared type
2817    // parameters are known before an importer's calls are checked.
2818    bynk_check::checker::reset_compared_cache();
2819    // #2137: the contexts a `system` harness stands up as Workers; their
2820    // test-build `Clock` reads the harness clock. Empty in a deployable build,
2821    // which carries no suites.
2822    let harness_clock_contexts = if parsed.iter().any(|p| p.integration().is_some()) {
2823        let tables: HashMap<String, UnitTable> = unit_info
2824            .iter()
2825            .map(|(n, i)| (n.clone(), i.table.clone()))
2826            .collect();
2827        let consumes: HashMap<String, Vec<String>> = unit_info
2828            .iter()
2829            .map(|(n, i)| (n.clone(), i.consumes.clone()))
2830            .collect();
2831        harness_clock_contexts(&parsed, &tables, &consumes, &kinds)
2832    } else {
2833        HashSet::new()
2834    };
2835    for name in bynk_check::project_model::uses_first_order(unit_info.keys(), &unit_uses) {
2836        let info = &unit_info[name];
2837        let kind = info.kind;
2838        let indices = info.files.as_slice();
2839        let local_table = &info.table;
2840        // v0.24: skip resolve/check only when THIS group's composition
2841        // failed. In build mode the sink is empty here (the structural gate
2842        // bailed), so the delta equals the old global is_empty check; in
2843        // analyse mode one broken unit no longer suppresses every other
2844        // unit's semantic diagnostics.
2845        let group_error_baseline = errors.len();
2846
2847        let (
2848            mut combined_types,
2849            combined_fns,
2850            mut combined_methods,
2851            mut imported_from,
2852            mut imported_from_kind,
2853        ) = project_model::compose_unit_symbols(name, local_table, &unit_info);
2854        let consumed_types = project_model::merge_consumed_exports(
2855            name,
2856            &parsed,
2857            &unit_info,
2858            &mut combined_types,
2859            &mut combined_methods,
2860            &mut imported_from,
2861            &mut imported_from_kind,
2862            &mut errors,
2863        );
2864        // #1807: close over the types the imported declarations reach.
2865        let reached_types = project_model::close_reachable_types(
2866            name,
2867            &parsed,
2868            &unit_info,
2869            &mut combined_types,
2870            &combined_fns,
2871            &mut combined_methods,
2872            &mut imported_from,
2873            &mut imported_from_kind,
2874            &mut errors,
2875        );
2876
2877        if errors.len() > group_error_baseline {
2878            continue;
2879        }
2880
2881        let local_names: HashSet<String> = local_table.types.keys().cloned().collect();
2882
2883        // #2252: the rule `emit_boundary_helpers` roots a consumed type's
2884        // codecs by (#2036): imported from a context, and not declared here.
2885        if kind == UnitKind::Context {
2886            let from_contexts = imported_from_kind
2887                .iter()
2888                .filter(|(n, k)| **k == UnitKind::Context && !local_names.contains(*n))
2889                .filter(|(n, _)| {
2890                    imported_from
2891                        .get(*n)
2892                        .and_then(|owner| unit_info.get(owner))
2893                        .is_some_and(|i| i.kind == UnitKind::Context)
2894                })
2895                .map(|(n, _)| n.clone())
2896                .collect();
2897            unit_consumed_context_types.insert(name.clone(), from_contexts);
2898        }
2899
2900        let local_methods_for_type = project_model::collect_unit_methods(indices, &parsed);
2901
2902        // Per-context view information for the emitter and checker.
2903        let owning_context_for_emit = if kind == UnitKind::Context {
2904            Some(name.clone())
2905        } else {
2906            None
2907        };
2908
2909        // #527: workers contexts get a DO-side deps plan for their agents'
2910        // `given` capabilities (the wire cannot carry providers).
2911        let agent_deps_plan = if matches!(target, BuildTarget::Workers) && kind == UnitKind::Context
2912        {
2913            plan_agent_given_deps(
2914                name,
2915                &unit_info,
2916                &adapter_bindings,
2917                indices.len() > 1,
2918                harness_clock_contexts.contains(name),
2919            )
2920        } else {
2921            None
2922        };
2923
2924        check_unit_files(
2925            name,
2926            kind,
2927            &broken,
2928            indices,
2929            &parsed,
2930            &unit_info,
2931            &combined_types,
2932            &combined_fns,
2933            &combined_methods,
2934            &local_names,
2935            &local_methods_for_type,
2936            &consumed_types,
2937            &imported_from,
2938            &imported_from_kind,
2939            &reached_types,
2940            &owning_context_for_emit,
2941            target,
2942            import_ext,
2943            contracts,
2944            agent_deps_plan.as_ref(),
2945            &history_target_agents,
2946            &asset_served,
2947            &embed_text,
2948            mode,
2949            &policy,
2950            &mut errors,
2951            &mut refs,
2952            &mut hints,
2953            &mut locals,
2954            &mut exprs,
2955            &mut requirements,
2956            &mut compiled,
2957            &schema_effective_versions,
2958            tys,
2959            unit_callees.entry(name.clone()).or_default(),
2960            unit_event_subscriber_shapes
2961                .entry(name.clone())
2962                .or_default(),
2963        );
2964    }
2965
2966    // v0.7: process test declarations. Each `test commerce.X` group resolves
2967    // its target, validates mocks against the target's capability/consumed-
2968    // context shapes, type-checks bodies with the target's privileged view,
2969    // and emits a per-target TypeScript test module under `tests/`.
2970    let mut test_errors: Vec<CompileError> = Vec::new();
2971    // v0.132: barrel output paths emitted so far, shared across the unit- and
2972    // integration-test passes so a multi-file commons imported by both is
2973    // aggregated into `out/<name>.ts` exactly once.
2974    let mut emitted_barrels: HashSet<PathBuf> = HashSet::new();
2975    // #1820: a context split across files emits a module per file and none at
2976    // `out/<name>.ts`, which the bundle's composition root imports it from.
2977    // Its barrel is part of the build, not only of a test build; a suite that
2978    // imports the context reuses it. On workers the context's barrel is its
2979    // Worker's `handlers.ts` (`emit_worker_handlers_barrel`), and its files
2980    // aren't at `out/<name>/`, so its path here is claimed and none is
2981    // emitted: the test passes below emit only a commons' barrel.
2982    for (name, kind) in &kinds {
2983        if *kind != UnitKind::Context {
2984            continue;
2985        }
2986        if mode == Mode::Build && target == BuildTarget::Bundle {
2987            if let Some(barrel) = emit_commons_barrel(
2988                name,
2989                &groups,
2990                &parsed,
2991                import_ext,
2992                &mut emitted_barrels,
2993                &compiled,
2994            ) {
2995                compiled.push(barrel);
2996            }
2997        } else if target == BuildTarget::Workers {
2998            emitted_barrels.insert(commons_dir_for(name).with_extension("ts"));
2999        }
3000    }
3001    // #1883, slice 3: the declared assets, for a case's `svc.GET(asset)`.
3002    let no_asset_bytes = BTreeMap::new();
3003    let test_asset_files = TestAssetFiles {
3004        served: &asset_served,
3005        bytes: asset_bytes.as_ref().unwrap_or(&no_asset_bytes),
3006    };
3007    // #1975: a user adapter's binding module, for a test that builds the
3008    // binding for real. A first-party adapter's binding reaches the platform
3009    // (the network, a KV namespace), so a test keeps its double. `bynk.crypto`
3010    // is the exception (K4): its binding is Web Crypto over the test's own
3011    // bytes, deterministic and I/O-free, so a test runs it for real and a fake
3012    // would check nothing.
3013    let test_binding_modules: HashMap<String, String> = adapter_bindings
3014        .iter()
3015        .filter(|(name, _)| {
3016            let first_party = bynk_check::firstparty::BYNK_UNIT;
3017            name.as_str() == bynk_check::firstparty::CRYPTO_UNIT
3018                || (name.as_str() != first_party
3019                    && !name
3020                        .strip_prefix(first_party)
3021                        .is_some_and(|rest| rest.starts_with('.')))
3022        })
3023        .map(|(name, b)| {
3024            (
3025                name.clone(),
3026                emitter::ts_specifier(&b.output_path.with_extension("")),
3027            )
3028        })
3029        .collect();
3030    let (test_outputs, runnable_tests) = process_tests(
3031        &test_groups,
3032        &parsed,
3033        &kinds,
3034        &unit_tables,
3035        &exports_visibility,
3036        &unit_consumes,
3037        &unit_consumes_aliases,
3038        &unit_uses,
3039        &unit_flattened,
3040        &groups,
3041        import_ext,
3042        contracts,
3043        &mut emitted_barrels,
3044        &test_asset_files,
3045        &test_binding_modules,
3046        &schema_effective_versions.events,
3047        &unit_callees,
3048        &mut test_errors,
3049        &mut refs,
3050        tys,
3051    );
3052    // #2138: a remote run runs only the `system` suites; the unit modules are
3053    // still written (a barrel they emit may be shared) but not listed.
3054    let runnable_tests = if remote_tests.is_some() {
3055        Vec::new()
3056    } else {
3057        runnable_tests
3058    };
3059    // #696/#1659: test-suite diagnostics are attributed to their owning file by
3060    // each error's span (`ErrorSink::extend_attributed_by_span`), so they render
3061    // with a file, line and span like every other diagnostic.
3062    errors.extend_attributed_by_span(&parsed, test_errors);
3063
3064    compiled.extend(test_outputs);
3065
3066    // v0.16: process integration tests. Each `test integration "name"` suite
3067    // validates its `wires` participants, type-checks each case body as a
3068    // cross-context call from a synthetic harness root that consumes every
3069    // participant, and emits a TypeScript module that stands the participants
3070    // up as in-process Workers and exercises the flow across the real wire.
3071    // #1959: each context's bindings, once: the integration harness binds them
3072    // on each participant's `env`, and the duplicate check below reads them.
3073    // Gated like that check: the KV input walks the provider graph.
3074    let event_subscribers =
3075        bynk_check::symbols::discover_event_subscribers(&unit_tables, &unit_consumes);
3076    let context_bindings = errors.is_empty().then(|| {
3077        worker_bindings::all_context_bindings(
3078            &kinds,
3079            &unit_tables,
3080            &unit_consumes,
3081            &unit_consumes_aliases,
3082            &unit_flattened,
3083            &unit_callees,
3084            &event_subscribers,
3085        )
3086    });
3087    let mut integration_errors: Vec<CompileError> = Vec::new();
3088    let (integration_outputs, integration_runnables) = process_integration_tests(
3089        &integration_groups,
3090        &parsed,
3091        &kinds,
3092        &unit_tables,
3093        &unit_consumes,
3094        &unit_consumes_aliases,
3095        &unit_uses,
3096        &groups,
3097        &mut emitted_barrels,
3098        &test_asset_files,
3099        context_bindings.as_ref(),
3100        &event_subscribers,
3101        remote_tests,
3102        &mut integration_errors,
3103        &mut refs,
3104        tys,
3105    );
3106    // #696/#1659: integration-suite diagnostics, attributed the same way.
3107    errors.extend_attributed_by_span(&parsed, integration_errors);
3108
3109    // v0.19 (decisions 0017/0024): platform-lock enforcement. A deployment
3110    // unit whose in-process closure reaches a platform-native capability is
3111    // locked to that platform; the selected `--platform` must match. Run only
3112    // on otherwise-clean programs: the closure walk recurses the provider
3113    // graph, whose acyclicity the earlier checks establish. P5.3: relocated
3114    // to `bynk_check::project_model::phase_platform_lock` — this crate is a
3115    // caller, not an owner.
3116    if errors.is_empty() {
3117        project_model::phase_platform_lock(
3118            target,
3119            platform,
3120            &parsed,
3121            &groups,
3122            &kinds,
3123            &unit_tables,
3124            &unit_consumes,
3125            &unit_consumes_aliases,
3126            &unit_flattened,
3127            &mut errors,
3128        );
3129    }
3130    // On Workers, each Worker's binding names are unique. Gated the same way:
3131    // the KV input walks the same provider graph.
3132    if errors.is_empty()
3133        && let Some(bindings) = &context_bindings
3134    {
3135        worker_bindings::phase_duplicate_bindings(target, &parsed, &groups, bindings, &mut errors);
3136    }
3137
3138    // v0.176 (#642): the `Bytes`-at-a-workers-boundary guard (ADR 0142 D8) is
3139    // retired here. It existed because the workers boundary carried its own
3140    // codec dispatch, which cast a `Bytes` to `JsonValue` on the way out while
3141    // base64-decoding it on the way in — so a `Bytes` mis-round-tripped, and a
3142    // diagnostic was better than silent corruption. That dispatch is gone: every
3143    // wire position now routes through `serialisation.rs`, whose `Bytes` arm
3144    // base64-encodes. The restriction has no remaining cause, and ADR 0142 D8's
3145    // deferral to "the roadmap's typed cross-context boundary fix" is discharged.
3146
3147    RunChecks::Checked {
3148        errors,
3149        snapshots,
3150        locals,
3151        exprs,
3152        parsed,
3153        compiled,
3154        runnable_tests,
3155        integration_outputs,
3156        integration_runnables,
3157        groups,
3158        kinds,
3159        unit_uses,
3160        unit_consumes,
3161        unit_consumes_aliases,
3162        unit_tables,
3163        unit_consumed_context_types,
3164        unit_callees,
3165        unit_event_subscriber_shapes,
3166        unit_flattened,
3167        adapter_bindings,
3168        npm_deps,
3169        target,
3170        schema_registry: schema_registry_doc,
3171        asset_bytes,
3172        agent_versions: schema_effective_versions.agents,
3173    }
3174}
3175
3176/// `<name>.<suffix>`, matching `bynk-driver::output.rs`'s own sibling-name
3177/// derivation for `.map`/`.bynkdbg.json` files exactly (P7.6, #1309).
3178pub fn sibling_path(output_path: &Path, suffix: &str) -> PathBuf {
3179    let name = output_path
3180        .file_name()
3181        .map(|n| n.to_string_lossy().into_owned())
3182        .unwrap_or_default();
3183    output_path.with_file_name(format!("{name}.{suffix}"))
3184}
3185
3186/// Build-success tail (region 3): emit the composition/worker/runtime files
3187/// and assemble the final `ProjectOutput`. Reached only on build mode with a
3188/// clean error sink. Moved verbatim from the old pipeline; only the locals it
3189/// reads are now bound from the `Checked` variant.
3190#[allow(clippy::too_many_arguments)]
3191fn build_output(
3192    parsed: Vec<ParsedFile>,
3193    mut compiled: Vec<StagedFile>,
3194    mut runnable_tests: Vec<RunnableTest>,
3195    integration_outputs: Vec<StagedFile>,
3196    integration_runnables: Vec<RunnableTest>,
3197    groups: BTreeMap<String, Vec<usize>>,
3198    kinds: BTreeMap<String, UnitKind>,
3199    unit_consumes: HashMap<String, Vec<String>>,
3200    unit_consumes_aliases: HashMap<String, HashMap<String, String>>,
3201    unit_tables: HashMap<String, UnitTable>,
3202    unit_consumed_context_types: &HashMap<String, HashSet<String>>,
3203    unit_callees: HashMap<String, HashMap<ExprId, bynk_check::checker::Callee>>,
3204    // P6.x (#1232): see `EventSubscriberShape`'s own doc comment.
3205    unit_event_subscriber_shapes: HashMap<String, HashMap<String, EventSubscriberShape>>,
3206    // v0.177 (#643): needed to build each context's *own* combined type table,
3207    // so its contract hashes are computed from the same namespace a caller sees.
3208    unit_uses: HashMap<String, Vec<String>>,
3209    unit_flattened: HashMap<String, HashMap<String, String>>,
3210    adapter_bindings: HashMap<String, AdapterBinding>,
3211    npm_deps: std::collections::BTreeMap<String, String>,
3212    target: BuildTarget,
3213    import_ext: ImportExt,
3214    // #1890: the merged `wrangler.toml` flag list (`finish_build` merges it).
3215    compatibility_flags: &[String],
3216    // #1883, slice 1b: the declared asset files' bytes; each Worker ships
3217    // the files its `uses` closure declares.
3218    asset_files: Option<&BTreeMap<String, Vec<u8>>>,
3219    // State-migrations S4 (#2259): each agent class's lock version, keyed
3220    // `<unit>.<Agent>`, for its Worker's `/_bynk/migrate/` route.
3221    agent_versions: &HashMap<String, bynk_check::schema_registry::AgentVersions>,
3222) -> ProjectOutput {
3223    // #1883, slice 1b: each declared asset's served path, and the files the
3224    // Workers below stage into their assets directories (empty for a Bundle
3225    // build: only a Worker serves files).
3226    let asset_served = asset_files
3227        .map(|files| bynk_check::assets::declared_served_paths(&parsed, files))
3228        .unwrap_or_default();
3229    let mut staged_assets: BTreeMap<PathBuf, Vec<u8>> = BTreeMap::new();
3230
3231    // #1655 (runtime-semantics track S6): a `--target workers` build writes the
3232    // workers layout (`workers/<ctx>/…`), but a *unit* test module imports the
3233    // bundle layout (`./../<ctx>.js`), so `tsc` over the output failed
3234    // (TS2307). Those modules had no consumer either: `bynkc test` compiles the
3235    // bundle target for them and overlays a workers compile *excluding*
3236    // `tests/`. A workers build therefore drops the unit modules. Integration
3237    // modules stand their participants up as real Workers and import this
3238    // layout, so they stay, and `tests/main.ts` runs just them.
3239    let workers = target == BuildTarget::Workers;
3240    if workers {
3241        compiled.retain(|f| !f.output_path.starts_with("tests"));
3242    }
3243    compiled.extend(integration_outputs);
3244    // The integration runnables follow the unit ones.
3245    let unit_count = runnable_tests.len();
3246    runnable_tests.extend(integration_runnables);
3247
3248    // v0.67: the discovery manifest — built from the combined runnable set before
3249    // anything consumes it, so `--no-run --format json` lists suites/cases without
3250    // running. Ordered by the runner's sort key to match a run's suite order.
3251    // On `workers` it lists only what this build writes and `tests/main.ts` runs
3252    // (#1655, review of #1695): the integration suites. `bynkc test` reads the
3253    // manifest from its bundle compile, so the unit suites are still listed
3254    // there.
3255    let discovered = if workers {
3256        discovery_manifest(&runnable_tests[unit_count..])
3257    } else {
3258        discovery_manifest(&runnable_tests)
3259    };
3260
3261    // v0.16: emit the combined top-level test runner once both passes are done,
3262    // so `tests/main.ts` aggregates unit and integration suites together (on
3263    // `workers`, the integration suites only: #1655, above).
3264    let main_tests = if workers {
3265        &runnable_tests[unit_count..]
3266    } else {
3267        &runnable_tests[..]
3268    };
3269    if !main_tests.is_empty() {
3270        let main_program = emit_test_main(main_tests, import_ext);
3271        compiled.push(StagedFile {
3272            output_path: PathBuf::from("tests/main.ts"),
3273            document: Document::Ts(main_program),
3274            source_map: None,
3275            debug_metadata: None,
3276        });
3277    }
3278
3279    // v0.19 (decision 0025): does any context's in-process closure reach a
3280    // platform-native unit? Drives env threading (bundle) and the per-Worker
3281    // Env/`wrangler.toml` resource derivation (workers).
3282    let context_native = worker_bindings::contexts_native(
3283        &kinds,
3284        &unit_tables,
3285        &unit_consumes,
3286        &unit_consumes_aliases,
3287        &unit_flattened,
3288        true,
3289    );
3290    // S4a (#1966): the same, counting only the bare resources, so a named
3291    // instance never brings in an unreachable bare `KV` namespace.
3292    let context_bare_native = worker_bindings::contexts_native(
3293        &kinds,
3294        &unit_tables,
3295        &unit_consumes,
3296        &unit_consumes_aliases,
3297        &unit_flattened,
3298        false,
3299    );
3300
3301    // Events track, slice 0 (spine #936): project-wide "who subscribes to
3302    // what", built once and shared by both targets — Bundle mode's
3303    // `composeApp` dispatches in-process; Workers mode uses it to size each
3304    // publishing context's fan-out DO routing table and wrangler.toml
3305    // Service Bindings.
3306    let event_subscribers =
3307        bynk_check::symbols::discover_event_subscribers(&unit_tables, &unit_consumes);
3308
3309    // #2137: the contexts a `system` harness stands up (test build only).
3310    let harness_clock_contexts =
3311        harness_clock_contexts(&parsed, &unit_tables, &unit_consumes, &kinds);
3312
3313    match target {
3314        BuildTarget::Bundle => {
3315            // v0.6 §6.3: emit a composition root when the project has at
3316            // least one context that consumes another context's service
3317            // surface. The compose file imports each context, instantiates
3318            // its providers, assembles its deps (capabilities + cross-
3319            // context surfaces), and exports the top-level service surface.
3320            if let Some(compose_program) = emit_composition_root(
3321                &groups,
3322                &kinds,
3323                &unit_consumes,
3324                &unit_consumes_aliases,
3325                &unit_tables,
3326                &unit_callees,
3327                &unit_event_subscriber_shapes,
3328                &adapter_bindings,
3329                &unit_flattened,
3330                // D1: thread `env` through composeApp only when a native
3331                // resource is consumed, so native-free programs are
3332                // byte-identical to v0.18 output.
3333                !context_native.is_empty(),
3334                &event_subscribers,
3335            ) {
3336                compiled.push(StagedFile {
3337                    output_path: PathBuf::from("compose.ts"),
3338                    document: Document::Ts(compose_program),
3339                    source_map: None,
3340                    debug_metadata: None,
3341                });
3342            }
3343        }
3344        BuildTarget::Workers => {
3345            // v0.8 §2.3: per-Worker entry point, compose.ts, and wrangler
3346            // configuration. One Worker per context.
3347            for (ctx_name, kind) in &kinds {
3348                if *kind != UnitKind::Context {
3349                    continue;
3350                }
3351                let Some(table) = unit_tables.get(ctx_name) else {
3352                    continue;
3353                };
3354                let dashes = worker_dir_name(ctx_name);
3355                let consumes_targets = unit_consumes.get(ctx_name).cloned().unwrap_or_default();
3356                let aliases = unit_consumes_aliases
3357                    .get(ctx_name)
3358                    .cloned()
3359                    .unwrap_or_default();
3360                // v0.177 (#643): the callee's own view of each of its `on call`
3361                // contracts, hashed from *its own* namespace — the same table
3362                // (`combined_types_for`) a caller reaches through
3363                // `consumed_types[ctx_name]`, so the two sides cannot disagree.
3364                let own_types = bynk_check::symbols::contract_types_for(
3365                    ctx_name,
3366                    &unit_tables,
3367                    &unit_uses,
3368                    &unit_consumes,
3369                )
3370                .0;
3371                let own_contracts = bynk_check::contract::own_contract_hashes(table, &own_types);
3372                let binding_modules: HashMap<String, String> = adapter_bindings
3373                    .iter()
3374                    .map(|(n, b)| {
3375                        (
3376                            n.clone(),
3377                            emitter::ts_specifier(&b.output_path.with_extension("js")),
3378                        )
3379                    })
3380                    .collect();
3381                let flattened = unit_flattened.get(ctx_name).cloned().unwrap_or_default();
3382                // v0.19 (C1): this Worker needs the KV namespace binding when
3383                // its in-process closure reaches the cloudflare adapter.
3384                let needs_kv = worker_bindings::needs_kv(&context_bare_native, ctx_name);
3385                // Locale capability track, slice 2 (#882, Decision A): real
3386                // negotiation is Cloudflare-only — `BuildTarget::Workers`
3387                // isn't itself restricted to `Platform::Cloudflare`, so a
3388                // hypothetical `--target workers --platform node` project
3389                // must not try to pass 3 args to Node's still-0-arg
3390                // `LocaleProvider`.
3391                let is_cloudflare_binding = adapter_bindings
3392                    .get(firstparty::BYNK_UNIT)
3393                    .is_some_and(|b| {
3394                        b.output_path.file_name()
3395                            == Some(std::ffi::OsStr::new(
3396                                firstparty::Platform::Cloudflare.bynk_binding_filename(),
3397                            ))
3398                    });
3399                let bundle = bynk_check::symbols::detect_context_message_bundle(
3400                    ctx_name, &unit_uses, &groups, &kinds, &parsed,
3401                );
3402                let locale_bundle_info = match &bundle {
3403                    bynk_check::symbols::ContextMessageBundle::One(info)
3404                        if is_cloudflare_binding =>
3405                    {
3406                        Some(info)
3407                    }
3408                    _ => None,
3409                };
3410                // #1187's slice 6 plumbing: computed once, reused by every
3411                // Workers-target emitter below that needs it.
3412                let ctx_uses_emit = unit_table_uses_emit(table, unit_callees.get(ctx_name));
3413                // #1944: every binding this Worker declares, once — its
3414                // `Env` interface and its `wrangler.toml` are both built from
3415                // it, and the duplicate check read the same list.
3416                let own_event_routes =
3417                    worker_bindings::own_event_routes(ctx_name, &event_subscribers);
3418                // What this context calls on each dependency (#1961): its
3419                // Service Bindings, and below, its contracts manifest.
3420                let called = called_cross_context_services(
3421                    table,
3422                    &consumes_targets,
3423                    unit_callees.get(ctx_name),
3424                );
3425                let called_contexts = worker_bindings::called_contexts(
3426                    table,
3427                    &consumes_targets,
3428                    Some(&aliases),
3429                    &kinds,
3430                    unit_callees.get(ctx_name),
3431                );
3432                let bindings = worker_bindings::context_bindings(
3433                    table,
3434                    &called_contexts,
3435                    &own_event_routes,
3436                    needs_kv,
3437                    ctx_uses_emit,
3438                );
3439                let (compose_ts, needs_locale_request) = emitter::emit_worker_compose(
3440                    ctx_name,
3441                    table,
3442                    &consumes_targets,
3443                    &aliases,
3444                    &unit_tables,
3445                    &binding_modules,
3446                    &flattened,
3447                    &unit_consumes,
3448                    &unit_consumes_aliases,
3449                    &unit_flattened,
3450                    locale_bundle_info,
3451                    import_ext,
3452                    ctx_uses_emit,
3453                    &bindings,
3454                    harness_clock_contexts.contains(ctx_name),
3455                );
3456                // #2136: a context a `system` suite targets gets the test
3457                // build's scheduled-result hook. A deployable build strips
3458                // suites (`tests` off), so it never does.
3459                let system_driven = parsed
3460                    .iter()
3461                    .filter_map(ParsedFile::integration)
3462                    .any(|d| d.target.joined() == *ctx_name);
3463                // #2252: the types this context takes from the contexts it
3464                // consumes, recorded when its handlers were checked.
3465                let no_consumed_types = HashSet::new();
3466                let consumed_types = unit_consumed_context_types
3467                    .get(ctx_name)
3468                    .unwrap_or(&no_consumed_types);
3469                // State-migrations S4 (#2259 Decision D): the context's
3470                // versioned agent classes, which its Worker's signed
3471                // `/_bynk/migrate/` route can wake.
3472                let mut migratable: Vec<(String, i64)> = agent_versions
3473                    .iter()
3474                    .filter_map(|(class, v)| {
3475                        let (unit, agent) = class.rsplit_once('.')?;
3476                        (unit == ctx_name && table.agents.contains_key(agent))
3477                            .then(|| (agent.to_string(), v.version))
3478                    })
3479                    .collect();
3480                migratable.sort();
3481                let entry_ts = emitter::emit_worker_entry(
3482                    ctx_name,
3483                    table,
3484                    &own_contracts,
3485                    needs_locale_request,
3486                    ctx_uses_emit,
3487                    system_driven,
3488                    consumed_types,
3489                    &migratable,
3490                );
3491                // P6.x cutover slice 2 (#1191): collected here, not inside
3492                // `emit_wrangler_toml` itself, so that function's own file
3493                // needs no raw-AST match. P6.46 (#1137): the walk itself
3494                // relocated to `bynk_check::symbols::cron_and_queue_triggers`
3495                // — a pure function of `table`, `project.rs`'s own remaining
3496                // AST contact here was incidental to where the loop happened
3497                // to be written, not structural.
3498                let (crons, queues) = bynk_check::symbols::cron_and_queue_triggers(table);
3499                let mut wrangler_doc = emitter::emit_wrangler_toml(
3500                    ctx_name,
3501                    &bindings,
3502                    &crons,
3503                    &queues,
3504                    compatibility_flags,
3505                    &emitter::ClassTombstones::of(table),
3506                );
3507                // #1883, slice 1b: the static files this Worker serves — its
3508                // own and those of every commons its `uses` closure reaches —
3509                // under its assets directory, with `_headers` and the
3510                // `[assets]` block. A Worker with none gets neither.
3511                let deployed = bynk_check::assets::deployed_assets(
3512                    ctx_name,
3513                    &unit_uses,
3514                    &unit_consumes,
3515                    &unit_tables,
3516                    &asset_served,
3517                );
3518                if !deployed.is_empty() {
3519                    let dir = worker_assets_dir(&dashes);
3520                    for (served, source) in &deployed {
3521                        let bytes = asset_files
3522                            .and_then(|files| files.get(source))
3523                            .cloned()
3524                            .unwrap_or_else(|| {
3525                                panic!(
3526                                    "bynk internal error: deployed asset `{source}` was never read"
3527                                )
3528                            });
3529                        staged_assets.insert(dir.join(served.trim_start_matches('/')), bytes);
3530                    }
3531                    staged_assets.insert(
3532                        dir.join("_headers"),
3533                        emitter::wrangler::asset_headers().into_bytes(),
3534                    );
3535                    wrangler_doc.push_block(emitter::wrangler::assets_block());
3536                }
3537                // L7 (#2414): an `EdgeCache` instance turns Workers Cache on.
3538                if table.derived.values().any(|d| d.is_edge_cache()) {
3539                    wrangler_doc.push_block(emitter::wrangler::edge_cache_block());
3540                }
3541                // #2321: a Worker whose adapter closure reaches an included
3542                // WebAssembly module gets the explicit `CompiledWasm` rule.
3543                if worker_reaches_wasm_module(ctx_name, &unit_consumes, &kinds, &adapter_bindings) {
3544                    wrangler_doc.push_block(emitter::wrangler::wasm_rule_block());
3545                }
3546                // Arc C slice 4 (#1323): `emit_worker_entry` returns a real
3547                // `TsProgram` directly — the construction site's own
3548                // `Verbatim`/`NotYetConverted` wrap is gone, matching the
3549                // precedent `events_fanout.rs`'s and `workers.rs`'s own
3550                // construction sites already set.
3551                compiled.push(StagedFile {
3552                    output_path: PathBuf::from(format!("workers/{dashes}/index.ts")),
3553                    document: Document::Ts(entry_ts),
3554                    source_map: None,
3555                    debug_metadata: None,
3556                });
3557                // Events track, slice 0: this context's fan-out Durable
3558                // Object — emitted only when it actually publishes (mirrors
3559                // `emit_worker_compose`'s own `unit_table_uses_emit` gate on
3560                // `deps.__eventsDispatch`, so the two never disagree about
3561                // whether `env.EVENTS_FANOUT` is real).
3562                if ctx_uses_emit {
3563                    // Arc C's own first real conversion slice (#1317):
3564                    // `emit_events_fanout_do` returns a real `TsProgram`
3565                    // directly — the first `bynk-emit` construction site
3566                    // that reaches `Document::Ts` with no `Verbatim` wrap.
3567                    let fanout_program =
3568                        emitter::emit_events_fanout_do(ctx_name, &own_event_routes);
3569                    compiled.push(StagedFile {
3570                        output_path: PathBuf::from(format!("workers/{dashes}/events_fanout.ts")),
3571                        document: Document::Ts(fanout_program),
3572                        source_map: None,
3573                        debug_metadata: None,
3574                    });
3575                }
3576                // Arc C slice 3 (#1321): `emit_worker_compose` returns a
3577                // real `TsProgram` directly — the second `bynk-emit`
3578                // construction site (after `events_fanout.ts`'s own,
3579                // #1317) that reaches `Document::Ts` with no `Verbatim`
3580                // wrap.
3581                compiled.push(StagedFile {
3582                    output_path: PathBuf::from(format!("workers/{dashes}/compose.ts")),
3583                    document: Document::Ts(compose_ts),
3584                    source_map: None,
3585                    debug_metadata: None,
3586                });
3587                compiled.push(StagedFile {
3588                    output_path: PathBuf::from(format!("workers/{dashes}/wrangler.toml")),
3589                    document: Document::Toml(wrangler_doc),
3590                    source_map: None,
3591                    debug_metadata: None,
3592                });
3593                // v0.172 (ADR 0195 D5): the secret names this Worker's handlers
3594                // will read from `env`, for `deploy` to check before it pushes.
3595                // Emitted from the same seams the entry lowers, so the two
3596                // cannot describe different Workers.
3597                //
3598                // v0.173 (ADR 0196): plus the literal `bynk.Secrets` names it
3599                // reads, and whether that list is everything. The walk is here
3600                // rather than in the checker because it needs `unit_flattened`
3601                // to answer *whose* `Secrets` this is (D4) and the warning sink
3602                // to say when it cannot know a name — and `bynk-check` has
3603                // neither. Absent when there is nothing at all to say (D5).
3604                // The warnings half is dropped here: `run_checks` already raised
3605                // it, on the analyse path the editor shares.
3606                // v0.177 (#643): the contract hashes this context's entry
3607                // enforces, written where `deploy` can read them — so a skew is
3608                // refused at the push rather than discovered by live traffic.
3609                let own_types =
3610                    bynk_check::symbols::combined_types_for(ctx_name, &unit_tables, &unit_uses);
3611                // What this context expects of each dependency — the same hash
3612                // it stamps at each call site, computed the same way: in the
3613                // *dependency's* namespace, from the dependency's own table.
3614                //
3615                // Only the services this context actually **calls**. Recording
3616                // everything the dependency provides would refuse a deploy over a
3617                // service this caller never touches — a skew its runtime check
3618                // could never fire on. See `called_cross_context_services`.
3619                let mut expects: std::collections::BTreeMap<
3620                    String,
3621                    std::collections::BTreeMap<String, String>,
3622                > = std::collections::BTreeMap::new();
3623                for (dep, services) in &called {
3624                    let Some(dep_table) = unit_tables.get(dep) else {
3625                        continue;
3626                    };
3627                    let dep_types = bynk_check::symbols::contract_types_for(
3628                        dep,
3629                        &unit_tables,
3630                        &unit_uses,
3631                        &unit_consumes,
3632                    )
3633                    .0;
3634                    let all = bynk_check::contract::own_contract_hashes(dep_table, &dep_types);
3635                    let hashes: std::collections::BTreeMap<String, String> = all
3636                        .into_iter()
3637                        .filter(|(svc, _)| services.contains(svc))
3638                        .collect();
3639                    if !hashes.is_empty() {
3640                        expects.insert(dep.clone(), hashes);
3641                    }
3642                }
3643                // I4 (#1970): what each declared foreign caller's generated
3644                // client embeds, recorded beside `provides` so `deploy` can
3645                // hold it against the caller's pin.
3646                let foreign = emitter::client::foreign_contracts(table, &own_types);
3647                if let Some(manifest) = emitter::contracts::emit_contracts_manifest(
3648                    &bynk_check::contract::own_contract_hashes(table, &own_types),
3649                    &expects,
3650                    &foreign,
3651                ) {
3652                    compiled.push(StagedFile {
3653                        output_path: PathBuf::from(format!(
3654                            "workers/{dashes}/{}",
3655                            emitter::contracts::CONTRACTS_MANIFEST
3656                        )),
3657                        document: Document::Json(manifest),
3658                        source_map: None,
3659                        debug_metadata: None,
3660                    });
3661                }
3662
3663                // I4 (#1970): the typed TypeScript client a foreign caller
3664                // imports, when the context declares one.
3665                if let Some(client) = emitter::client::emit_client(ctx_name, table, &own_types) {
3666                    compiled.push(StagedFile {
3667                        output_path: PathBuf::from(format!(
3668                            "workers/{dashes}/{}",
3669                            emitter::client::CLIENT_FILE
3670                        )),
3671                        document: Document::Ts(client),
3672                        source_map: None,
3673                        debug_metadata: None,
3674                    });
3675                }
3676
3677                let reads = emitter::secrets::secret_reads(
3678                    ctx_name,
3679                    &unit_tables,
3680                    &unit_consumes,
3681                    &unit_consumes_aliases,
3682                    &unit_flattened,
3683                );
3684                if let Some(manifest) = emitter::emit_secrets_manifest(table, &reads) {
3685                    compiled.push(StagedFile {
3686                        output_path: PathBuf::from(format!(
3687                            "workers/{dashes}/{}",
3688                            emitter::secrets::SECRETS_MANIFEST
3689                        )),
3690                        document: Document::Json(manifest),
3691                        source_map: None,
3692                        debug_metadata: None,
3693                    });
3694                }
3695            }
3696        }
3697    }
3698
3699    // v0.17: copy each adapter binding verbatim into the output, beside the
3700    // adapter's emitted interface module, so compose's import resolves and the
3701    // `tsc` gate checks the `implements` contract.
3702    let mut binding_names: Vec<&String> = adapter_bindings.keys().collect();
3703    binding_names.sort();
3704    for name in binding_names {
3705        let b = &adapter_bindings[name];
3706        compiled.push(StagedFile {
3707            output_path: b.output_path.clone(),
3708            document: Document::Ts(bynk_ts::TsProgram {
3709                stmts: vec![bynk_ts::TsStmt::verbatim(
3710                    bynk_ts::VerbatimOrigin::NotYetConverted,
3711                    emitter::runtime_files::rewrite_runtime_imports(&b.content),
3712                    None,
3713                )],
3714            }),
3715            source_map: None,
3716            debug_metadata: None,
3717        });
3718    }
3719
3720    // #2321: each included WebAssembly module — its bytes beside the binding
3721    // (a module file, never served) and the loader shim the binding imports.
3722    let mut staged_modules: BTreeMap<PathBuf, Vec<u8>> = BTreeMap::new();
3723    for b in adapter_bindings.values() {
3724        for inc in &b.includes {
3725            staged_modules.insert(inc.output_path.clone(), inc.bytes.clone());
3726            compiled.push(StagedFile {
3727                output_path: wasm_shim_path(&inc.output_path),
3728                document: Document::Ts(bynk_ts::TsProgram {
3729                    stmts: vec![bynk_ts::TsStmt::verbatim(
3730                        bynk_ts::VerbatimOrigin::NotYetConverted,
3731                        workers_wasm_shim(&inc.output_path),
3732                        None,
3733                    )],
3734                }),
3735                source_map: None,
3736                debug_metadata: None,
3737            });
3738        }
3739    }
3740    if !staged_modules.is_empty() {
3741        compiled.push(StagedFile {
3742            output_path: PathBuf::from("wasm-modules.d.ts"),
3743            document: Document::Ts(bynk_ts::TsProgram {
3744                stmts: vec![bynk_ts::TsStmt::verbatim(
3745                    bynk_ts::VerbatimOrigin::NotYetConverted,
3746                    WASM_MODULES_DTS.to_string(),
3747                    None,
3748                )],
3749            }),
3750            source_map: None,
3751            debug_metadata: None,
3752        });
3753    }
3754
3755    // `package.json`: always `"type": "module"` (#2148), so the emitted
3756    // tsconfig's `module: NodeNext` checks and builds the output as the ES
3757    // modules it is, under Node's ESM rules; plus the adapter-declared npm
3758    // dependencies when there are any (v0.17).
3759    compiled.push(StagedFile {
3760        output_path: PathBuf::from("package.json"),
3761        document: Document::Json(render_package_json(&npm_deps)),
3762        source_map: None,
3763        debug_metadata: None,
3764    });
3765
3766    // Runtime (ADR 0615): exactly the modules the emitted files import, closed
3767    // over the modules' own imports, under `__bynk_runtime/` at the root of
3768    // `out/` so every file's relative specifier resolves to it.
3769    // `tsconfig.json` is also at the root so `tsc -p out/tsconfig.json`
3770    // discovers every `.ts` file in the tree.
3771    let mut reached = BTreeSet::new();
3772    for f in &compiled {
3773        if let Document::Ts(program) = &f.document {
3774            emitter::runtime_files::reached_in(program, &mut reached);
3775        }
3776    }
3777    for (path, source) in emitter::runtime_files::closure_files(&reached) {
3778        compiled.push(StagedFile {
3779            output_path: path,
3780            document: Document::Ts(bynk_ts::TsProgram {
3781                stmts: vec![bynk_ts::TsStmt::verbatim(
3782                    bynk_ts::VerbatimOrigin::NotYetConverted,
3783                    source,
3784                    None,
3785                )],
3786            }),
3787            source_map: None,
3788            debug_metadata: None,
3789        });
3790    }
3791    compiled.push(StagedFile {
3792        output_path: PathBuf::from("tsconfig.json"),
3793        document: Document::Json(emitter::emit_tsconfig()),
3794        source_map: None,
3795        debug_metadata: None,
3796    });
3797
3798    // `Artefacts.docs` is a `BTreeMap`, so it iterates in `output_path` order
3799    // naturally (Decision D, #1309) — no explicit sort needed here the way
3800    // the old `Vec<CompiledFile>` (sorted by `source_path`) required.
3801    let mut docs: BTreeMap<PathBuf, Document> = BTreeMap::new();
3802    for f in compiled {
3803        if let Some(sm) = &f.source_map {
3804            docs.insert(
3805                sibling_path(&f.output_path, "map"),
3806                Document::SourceMap(sm.clone()),
3807            );
3808        }
3809        if let Some(dbg) = &f.debug_metadata {
3810            docs.insert(
3811                sibling_path(&f.output_path, "bynkdbg.json"),
3812                Document::DebugSidecar(dbg.clone()),
3813            );
3814        }
3815        docs.insert(f.output_path, f.document);
3816    }
3817    ProjectOutput {
3818        artefacts: Artefacts {
3819            docs,
3820            assets: staged_assets,
3821            modules: staged_modules,
3822        },
3823        discovered,
3824        // Populated by `compile_project` from the run's warning sink (ADR 0117).
3825        warnings: Vec::new(),
3826        // Populated by `finish_build` from the same `RunChecks::Checked` this
3827        // whole `ProjectOutput` was built from.
3828        snapshots: Vec::new(),
3829        display_root: PathBuf::new(),
3830        // Likewise (#1078) — `Some` only when the registry was on.
3831        schema_lock: None,
3832    }
3833}
3834
3835// P5.3 review (#1133): `resolve_consume_prefix` and `handler_cross_caps` used
3836// to have their own copies here, byte-identical to `bynk-check::project_model`'s
3837// (neither builds TypeScript, so neither had the codegen coupling that keeps
3838// `instantiate_provider_ts_expr`/`native_platforms_of_context` below in this
3839// crate) — deleted, every call site repointed at
3840// `project_model::{resolve_consume_prefix, handler_cross_caps}`.
3841
3842/// v0.19 (decision 0017): the native platforms a context's **in-process
3843/// closure** commits it to: every unit whose provider its compose would
3844/// instantiate — local providers' `given` recursion plus the capabilities its
3845/// handlers reference — mapped through [`firstparty::platform_of`]. Each
3846/// platform carries an exemplar unit for the diagnostic message. Service
3847/// `consumes` edges (RPC under `workers`) do not contribute — only the
3848/// provider-instantiation walk, which is in-process by construction.
3849#[allow(clippy::too_many_arguments)]
3850fn native_platforms_of_context(
3851    ctx: &str,
3852    table: &UnitTable,
3853    unit_tables: &HashMap<String, UnitTable>,
3854    unit_consumes: &HashMap<String, Vec<String>>,
3855    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3856    unit_flattened: &HashMap<String, HashMap<String, String>>,
3857    // S4a (#1966): count derived capabilities' base units. `true` for what
3858    // threads `env`; `false` for whether the *bare* platform resource (the
3859    // `KV` namespace) is used, which a named instance alone must not imply.
3860    with_derived: bool,
3861) -> std::collections::BTreeMap<Platform, String> {
3862    // Arc F slice 2 (#1452): repointed at the tree-native twin — only the
3863    // `referenced` side effect matters here, the returned `TsExpr` (vs. the
3864    // old `String`) is discarded either way.
3865    let mut referenced: BTreeSet<String> = BTreeSet::new();
3866    let namespaces = if with_derived {
3867        ProviderNamespaces::Bundle
3868    } else {
3869        ProviderNamespaces::BareResources
3870    };
3871    for cap in table.providers.keys() {
3872        let _ = instantiate_provider_ts_expr(
3873            ctx,
3874            cap,
3875            unit_tables,
3876            unit_consumes,
3877            unit_consumes_aliases,
3878            unit_flattened,
3879            namespaces,
3880            None,
3881            None,
3882            &mut referenced,
3883        );
3884    }
3885    let consumed = unit_consumes.get(ctx).cloned().unwrap_or_default();
3886    let aliases = unit_consumes_aliases.get(ctx).cloned().unwrap_or_default();
3887    let flattened = unit_flattened.get(ctx).cloned().unwrap_or_default();
3888    for (key, cctx) in handler_cross_caps(table, &consumed, &aliases, &flattened) {
3889        let _ = instantiate_provider_ts_expr(
3890            &cctx,
3891            &key,
3892            unit_tables,
3893            unit_consumes,
3894            unit_consumes_aliases,
3895            unit_flattened,
3896            namespaces,
3897            None,
3898            None,
3899            &mut referenced,
3900        );
3901    }
3902    if with_derived {
3903        referenced.extend(table.derived.values().map(|d| d.base_unit.clone()));
3904    }
3905    let mut out = std::collections::BTreeMap::new();
3906    for unit in referenced {
3907        if let Some(p) = bynk_check::firstparty::platform_of(&unit) {
3908            out.entry(p).or_insert(unit);
3909        }
3910    }
3911    out
3912}
3913
3914/// #527: the DO-side deps plan for one workers context. Capability providers
3915/// cannot cross the DO wire (`{ args, deps }` is JSON — a provider's methods
3916/// die in serialisation), so the generated Durable Object reconstructs its
3917/// agents' `given` deps *inside* the DO from the same wiring compose uses.
3918#[derive(Debug, Default, Clone)]
3919pub struct AgentDepsPlan {
3920    /// agent name → TS object-literal expression for its `given` deps
3921    /// (e.g. `{ Clock: new bynk__binding.ClockProvider() }`).
3922    pub exprs: HashMap<String, String>,
3923    /// Import lines the expressions need in `handlers.ts` (binding modules,
3924    /// other Workers' handlers). Same relative depth as `compose.ts`.
3925    pub imports: Vec<String>,
3926}
3927
3928/// Build the [`AgentDepsPlan`] for context `name`, or `None` when no local
3929/// agent has `given` capabilities.
3930///
3931/// `split`: the context spans several files, each emitted as its own module
3932/// under `handlers/` (#1820, [`worker_file_source_path`]). The imports are
3933/// then one directory deeper, and this context's own providers are no longer
3934/// in the agent's module, so they are reached through `handlers.ts` like
3935/// another context's. That import is circular (`handlers.ts` re-exports the
3936/// agent's module), which is safe: the expressions are evaluated in the
3937/// Durable Object's fetch dispatch, never while the modules load.
3938fn plan_agent_given_deps(
3939    name: &str,
3940    unit_info: &BTreeMap<String, UnitInfo>,
3941    adapter_bindings: &HashMap<String, AdapterBinding>,
3942    split: bool,
3943    // #2137: whether a `system` harness stands this context up (test build).
3944    harness_clock: bool,
3945) -> Option<AgentDepsPlan> {
3946    let info = unit_info.get(name)?;
3947    info.table.agents.values().next()?;
3948    let unit_tables: HashMap<String, UnitTable> = unit_info
3949        .iter()
3950        .map(|(n, i)| (n.clone(), i.table.clone()))
3951        .collect();
3952    let unit_consumes: HashMap<String, Vec<String>> = unit_info
3953        .iter()
3954        .map(|(n, i)| (n.clone(), i.consumes.clone()))
3955        .collect();
3956    let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = unit_info
3957        .iter()
3958        .map(|(n, i)| (n.clone(), i.aliases.clone()))
3959        .collect();
3960    let unit_flattened: HashMap<String, HashMap<String, String>> = unit_info
3961        .iter()
3962        .map(|(n, i)| (n.clone(), i.flattened.clone()))
3963        .collect();
3964
3965    let mut referenced: BTreeSet<String> = BTreeSet::new();
3966    let mut exprs: HashMap<String, String> = HashMap::new();
3967    // P6.35 (design/tracks/the-ir.md §6a): no explicit `&AgentDecl` annotation
3968    // needed — the loop body's own `a.handlers` field access below already
3969    // pins the element type through inference.
3970    let mut agents: Vec<_> = info.table.agents.iter().collect();
3971    agents.sort_by_key(|(n, _)| (*n).clone());
3972    for (agent, a) in agents {
3973        // #1187's slice 6 (Agent/Service given wiring): reads bynk-emit::ir's
3974        // own CapRefIr (lower_handler_given_ir — a standalone reader mirroring
3975        // lower_provider_given_ir, #1200) instead of walking the raw AST
3976        // CapRef directly. `caps` stays keyed by bare name (declaration-order-
3977        // first dedup across every handler), just storing CapRefIr instead of
3978        // CapRef.
3979        let mut caps: std::collections::BTreeMap<String, CapRefIr> =
3980            std::collections::BTreeMap::new();
3981        for h in &a.handlers {
3982            for g in lower_handler_given_ir(h) {
3983                // Events track, slice 0 (spine #936): see the matching skip
3984                // in `handler_cross_caps` — no `EventsProvider` exists for
3985                // compose (or a synthesised DO's own reconstructed deps) to
3986                // build.
3987                if g.name == "Events"
3988                    && info.flattened.get(g.name.as_str()).map(String::as_str) == Some("bynk")
3989                {
3990                    continue;
3991                }
3992                caps.entry(g.name.clone()).or_insert(g);
3993            }
3994        }
3995        if caps.is_empty() {
3996            continue;
3997        }
3998        // Arc F slice 2 (#1452): `instantiate_provider_ts_expr` — the
3999        // tree-native twin `emit_worker_compose`/`emit_composition_root`
4000        // already use (#1321/#1327) — builds the same `new {ns}.{Class}(...)`
4001        // shape as a real `bynk_ts::TsExpr`, byte-identical once printed
4002        // (both share every recursive call/text-building step below the
4003        // top level; only this per-agent object wrapper is new). Boundary-
4004        // prints once, here, into `exprs: HashMap<String, String>` — the
4005        // struct's own shape and `emit_agent`'s `TsExpr::Ident(expr.clone())`
4006        // caller (`emitter/emit.rs`) both stay unchanged, the narrower of
4007        // the two options the issue named.
4008        let entries: Vec<(String, bynk_ts::TsExpr)> = caps
4009            .iter()
4010            .map(|(key, g)| {
4011                let target_ctx = match &g.context {
4012                    Some(p) => resolve_consume_prefix(p, &info.consumes, &info.aliases)
4013                        .unwrap_or_else(|| name.to_string()),
4014                    None => info
4015                        .flattened
4016                        .get(key.as_str())
4017                        .cloned()
4018                        .unwrap_or_else(|| name.to_string()),
4019                };
4020                let expr = instantiate_provider_ts_expr(
4021                    &target_ctx,
4022                    key,
4023                    &unit_tables,
4024                    &unit_consumes,
4025                    &unit_consumes_aliases,
4026                    &unit_flattened,
4027                    ProviderNamespaces::Workers { harness_clock },
4028                    Some("env"),
4029                    None,
4030                    &mut referenced,
4031                );
4032                (key.clone(), expr)
4033            })
4034            .collect();
4035        exprs.insert(
4036            agent.clone(),
4037            bynk_ts::print_expr(&bynk_ts::TsExpr::object(entries)),
4038        );
4039    }
4040    if exprs.is_empty() {
4041        return None;
4042    }
4043    // Providers of *this* context live in the same module (`handlers.ts`), so
4044    // their compose-namespace prefix drops. Not for a split context: there
4045    // they are imported from `handlers.ts` like any other context's (`../`
4046    // up from `handlers/` to the Worker's own directory, then back down).
4047    let self_ns = format!("handlers_{}.", name.replace('.', "_"));
4048    if !split {
4049        for e in exprs.values_mut() {
4050            *e = e.replace(&self_ns, "");
4051        }
4052        referenced.remove(name);
4053    }
4054    let up = if split { "../" } else { "" };
4055    let mut imports = Vec::new();
4056    for u in &referenced {
4057        let ns = u.replace('.', "_");
4058        if let Some(b) = adapter_bindings.get(u) {
4059            let module = crate::emitter::ts_specifier(&b.output_path.with_extension("js"));
4060            imports.push(format!(
4061                "import * as {ns}__binding from \"{up}../../{module}\";"
4062            ));
4063        } else {
4064            let dir = worker_dir_name(u);
4065            imports.push(format!(
4066                "import * as handlers_{ns} from \"{up}../{dir}/handlers.js\";"
4067            ));
4068        }
4069    }
4070    Some(AgentDepsPlan { exprs, imports })
4071}
4072
4073/// v0.15: build the TypeScript expression instantiating the provider of
4074/// capability `cap` declared in `provider_ctx`, recursively wiring its `given`
4075/// dependencies — local sibling providers and cross-context capability
4076/// providers alike. Stateless providers, so fresh instances per use are fine.
4077///
4078/// v0.18 (spec §4.5/§5.1): a *bare* `given` name resolves through the
4079/// provider's own unit's flattened-capability map (`Fetch` → `bynk`), falling
4080/// back to the unit itself; an *external* provider's deps are built the same
4081/// way and passed to the binding class constructor by name. Every unit whose
4082/// namespace the expression references is recorded in `referenced_units` so
4083/// the caller can emit the matching imports (the transitive given-closure).
4084///
4085/// Locale capability track, slice 2 (#882, Decision C): the three extra
4086/// constructor arguments `LocaleProvider` receives when its composing
4087/// context has a uniquely-detected message bundle — the JS expressions
4088/// themselves (an identifier for `request`, and the two cross-commons-
4089/// imported bundle constants), not raw data, since they're spliced directly
4090/// into the generated `new bynk__binding.LocaleProvider(...)` call.
4091pub(crate) struct LocaleNegotiationArgs {
4092    pub(crate) request_expr: String,
4093    pub(crate) declared_locales_expr: String,
4094    pub(crate) reference_locale_expr: String,
4095}
4096
4097/// Where [`instantiate_provider_ts_expr`]'s expression finds each unit's
4098/// provider classes: the module that constructs it imports each unit under a
4099/// different namespace.
4100#[derive(Clone, Copy)]
4101pub(crate) enum ProviderNamespaces<'a> {
4102    /// The bundle's `compose.ts`: `{ns}`.
4103    Bundle,
4104    /// No module: [`native_platforms_of_context`]'s walk for the *bare*
4105    /// platform resources a context reaches (whether its Worker declares the
4106    /// `KV` namespace). As `Bundle`, except that a derived capability's
4107    /// instance (S4a, #1966) is not followed to its base: a named instance has
4108    /// its own binding and does not imply the bare one.
4109    BareResources,
4110    /// A Worker's `compose.ts` or `handlers.ts`: `handlers_{ns}`.
4111    ///
4112    /// #2137: `harness_clock` is set in the test build of a context a
4113    /// `system` harness stands up. Its first-party `Clock` then reads the
4114    /// harness clock the test module puts on `env.__bynkClock`, falling back to
4115    /// the real provider; a deployable build carries no suites, so it never
4116    /// sets it.
4117    Workers { harness_clock: bool },
4118    /// A test module (#1863): `__ns_{ns}`, the test scaffold's own.
4119    ///
4120    /// `prebuilt` names, by `(unit, capability)`, the local that holds a
4121    /// capability the test deps build at the top level: a provider's `given`
4122    /// that resolves to one is that local, not a fresh construction, so a
4123    /// `stub` overlaid on it reaches every provider built over it (#1864
4124    /// review). Each one so used is recorded in `used`.
4125    ///
4126    /// #1975: `bindings` names, by adapter, the binding module a test module
4127    /// imports as `{ns}__binding` when it builds that adapter's external
4128    /// provider for real (an adapter suite, or an `integration` case); an
4129    /// adapter absent from it is the placeholder. `double` answers a
4130    /// capability that has a deterministic test double (`__bynkTest_<Cap>()`)
4131    /// with that double, recording it, so a real binding's own `given` takes
4132    /// the tier's double rather than the platform's real provider.
4133    Test {
4134        prebuilt: &'a HashMap<(String, String), String>,
4135        used: &'a std::cell::RefCell<BTreeSet<(String, String)>>,
4136        bindings: Option<&'a HashMap<String, String>>,
4137        double: &'a dyn Fn(&str, &str) -> Option<bynk_ts::TsExpr>,
4138    },
4139}
4140
4141/// `new {ns}.{class}({args})` as a real [`bynk_ts::TsExpr::New`] node.
4142fn new_call_ts_expr(ns: &str, class: &str, args: Vec<bynk_ts::TsExpr>) -> bynk_ts::TsExpr {
4143    bynk_ts::TsExpr::New {
4144        callee: Box::new(bynk_ts::TsExpr::Member {
4145            object: Box::new(bynk_ts::TsExpr::Ident(ns.to_string())),
4146            property: class.to_string(),
4147        }),
4148        args,
4149    }
4150}
4151
4152/// `namespaces` selects the namespace convention ([`ProviderNamespaces`]): a
4153/// bodied provider's class lives in `{ns}` under the bundle root,
4154/// `handlers_{ns}` in a Worker compose and `__ns_{ns}` in a test module;
4155/// external (binding) classes are `{ns}__binding` in the first two, and a
4156/// test module has none. When `env_ident` is set (workers), env-taking
4157/// first-party providers receive it as a constructor argument.
4158///
4159/// Locale capability track, slice 2 (#882): `locale_negotiation`, when
4160/// `Some`, is threaded to exactly the `(bynk, LocaleProvider)` pair, the same
4161/// way `env_ident` is threaded to `provider_takes_env`'s pairs — a small,
4162/// closed set of first-party providers that need ambient, request-scoped
4163/// construction data no ordinary `given` clause could express.
4164///
4165/// Originally a `TsExpr`-returning twin *alongside* a `String`-returning
4166/// `instantiate_provider_expr` (the same "structural converter added
4167/// alongside the `String` one" pattern Decision B already uses for
4168/// `TypeRef -> TsType`) — #1321 (Arc C slice 3): `emitter::workers::
4169/// emit_worker_compose` now builds a real `TsProgram` directly, and its own
4170/// cross-context capability-provider `const {key} = {expr};` lines need a
4171/// real `TsExpr`, not a `String` to splice. `workers.rs`'s own
4172/// `emit_worker_compose` (Workers mode, `workers_ns: true`) and
4173/// `emit_composition_root`'s own Bundle-mode `compose.ts` (#1327, Arc C
4174/// slice 6, `workers_ns: false`) called this twin from the start. Originally
4175/// hardcoded `workers_ns = true` (the only mode `emit_worker_compose`'s own
4176/// call site used at the time) — #1327 restored the `workers_ns: bool`
4177/// parameter its then-`String`-returning sibling always had, matching that
4178/// signature exactly, once a second real caller needed `false`.
4179/// Arc F slice 2 (#1452): `plan_agent_given_deps`/`native_platforms_of_context`
4180/// (below in this file) repointed here too — `instantiate_provider_expr`
4181/// itself had no callers left and is deleted; its parameter-contract prose
4182/// and body rationale comments (below) moved here rather than being lost.
4183#[allow(clippy::too_many_arguments)]
4184pub(crate) fn instantiate_provider_ts_expr(
4185    provider_ctx: &str,
4186    cap: &str,
4187    unit_tables: &HashMap<String, UnitTable>,
4188    unit_consumes: &HashMap<String, Vec<String>>,
4189    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
4190    unit_flattened: &HashMap<String, HashMap<String, String>>,
4191    namespaces: ProviderNamespaces<'_>,
4192    env_ident: Option<&str>,
4193    locale_negotiation: Option<&LocaleNegotiationArgs>,
4194    referenced_units: &mut BTreeSet<String>,
4195) -> bynk_ts::TsExpr {
4196    // S4a (#1966): a derived capability (`capability Sessions = Kv`) is its
4197    // base's provider bound to the instance's own Worker binding: the base's
4198    // expression (`new bynk_cloudflare__binding.WorkersKv(env)`) with the
4199    // binding name as one more argument. Every composer (a Worker's compose,
4200    // the bundle's `composeApp`, an agent's deps, a provider's `given`) builds
4201    // an instance through here.
4202    if let Some(inst) = unit_tables
4203        .get(provider_ctx)
4204        .and_then(|t| t.derived.get(cap))
4205    {
4206        if matches!(namespaces, ProviderNamespaces::BareResources) {
4207            return bynk_ts::TsExpr::Ident("undefined".to_string());
4208        }
4209        let mut expr = instantiate_provider_ts_expr(
4210            &inst.base_unit,
4211            &inst.base_cap,
4212            unit_tables,
4213            unit_consumes,
4214            unit_consumes_aliases,
4215            unit_flattened,
4216            namespaces,
4217            env_ident,
4218            locale_negotiation,
4219            referenced_units,
4220        );
4221        // L7 (#2414): an `EdgeCache` instance has no binding: it purges
4222        // through the entry's `ctx`, which a Worker's compose takes as `exec`.
4223        // Anywhere else (the bundle) there is no edge cache, and the provider
4224        // built without one purges nothing.
4225        if inst.is_edge_cache() {
4226            if let (ProviderNamespaces::Workers { .. }, bynk_ts::TsExpr::New { args, .. }) =
4227                (namespaces, &mut expr)
4228            {
4229                args.push(bynk_ts::TsExpr::Ident("exec".to_string()));
4230            }
4231            return expr;
4232        }
4233        // Review of #1968: anything but a construction to append the binding
4234        // to would quietly read the default `KV`, so two instances would share
4235        // a namespace. The one other shape is a test module's placeholder for
4236        // a provider it cannot build, which has no binding to carry.
4237        match &mut expr {
4238            bynk_ts::TsExpr::New { args, .. } => args.push(bynk_ts::TsExpr::Lit(
4239                bynk_ts::TsLit::Str(inst.binding.clone()),
4240            )),
4241            bynk_ts::TsExpr::As { .. } if matches!(namespaces, ProviderNamespaces::Test { .. }) => {
4242            }
4243            _ => panic!(
4244                "derived capability `{cap}`: base `{}.{}` is not built by a construction, so binding `{}` has nowhere to go",
4245                inst.base_unit, inst.base_cap, inst.binding
4246            ),
4247        }
4248        return expr;
4249    }
4250    let ns = provider_ctx.replace('.', "_");
4251    let bodied_ns = match namespaces {
4252        ProviderNamespaces::Bundle | ProviderNamespaces::BareResources => ns.clone(),
4253        ProviderNamespaces::Workers { .. } => format!("handlers_{ns}"),
4254        ProviderNamespaces::Test { .. } => crate::emitter::emit::test_scaffold_ns(provider_ctx),
4255    };
4256    let provider = unit_tables
4257        .get(provider_ctx)
4258        .and_then(|t| t.providers.get(cap));
4259    // #1863: an external provider is the same placeholder as a capability
4260    // with no provider, unless (#1975) the test module builds this adapter's
4261    // binding for real; a capability with a test double takes the double.
4262    if let ProviderNamespaces::Test {
4263        bindings, double, ..
4264    } = namespaces
4265        && provider.is_none_or(|p| p.external)
4266        && !(provider.is_some() && bindings.is_some_and(|b| b.contains_key(provider_ctx)))
4267    {
4268        if let Some(d) = double(provider_ctx, cap) {
4269            return d;
4270        }
4271        return bynk_ts::TsExpr::As {
4272            expr: Box::new(bynk_ts::TsExpr::As {
4273                expr: Box::new(bynk_ts::TsExpr::Ident("undefined".to_string())),
4274                ty: bynk_ts::TsType::named("unknown"),
4275            }),
4276            ty: bynk_ts::TsType::named("never"),
4277        };
4278    }
4279    referenced_units.insert(provider_ctx.to_string());
4280    let Some(provider) = provider else {
4281        return new_call_ts_expr(&bodied_ns, cap, vec![]);
4282    };
4283    // Build the by-name deps object from the provider's `given`, if any.
4284    // #1187's Provider given/deps-wiring slice: reads bynk_ir's own CapRefIr
4285    // (lower_provider_given_ir — a standalone reader of the `given` clause,
4286    // never a full provider IR; see that function's own doc comment) instead
4287    // of walking the raw AST CapRef directly.
4288    let given: Vec<CapRefIr> = lower_provider_given_ir(provider);
4289    let deps_obj: Option<bynk_ts::TsExpr> = if given.is_empty() {
4290        None
4291    } else {
4292        let consumed = unit_consumes.get(provider_ctx).cloned().unwrap_or_default();
4293        let aliases = unit_consumes_aliases
4294            .get(provider_ctx)
4295            .cloned()
4296            .unwrap_or_default();
4297        let flattened = unit_flattened
4298            .get(provider_ctx)
4299            .cloned()
4300            .unwrap_or_default();
4301        let deps: Vec<(String, bynk_ts::TsExpr)> = given
4302            .iter()
4303            .map(|g| {
4304                let target_ctx = match &g.context {
4305                    Some(p) => resolve_consume_prefix(p, &consumed, &aliases)
4306                        .unwrap_or_else(|| provider_ctx.to_string()),
4307                    None => flattened
4308                        .get(&g.name)
4309                        .cloned()
4310                        .unwrap_or_else(|| provider_ctx.to_string()),
4311                };
4312                if let ProviderNamespaces::Test { prebuilt, used, .. } = namespaces {
4313                    let key = (target_ctx.clone(), g.name.clone());
4314                    if let Some(local) = prebuilt.get(&key) {
4315                        used.borrow_mut().insert(key);
4316                        return (g.name.clone(), bynk_ts::TsExpr::Ident(local.clone()));
4317                    }
4318                }
4319                let expr = instantiate_provider_ts_expr(
4320                    &target_ctx,
4321                    &g.name,
4322                    unit_tables,
4323                    unit_consumes,
4324                    unit_consumes_aliases,
4325                    unit_flattened,
4326                    namespaces,
4327                    env_ident,
4328                    locale_negotiation,
4329                    referenced_units,
4330                );
4331                (g.name.clone(), expr)
4332            })
4333            .collect();
4334        Some(bynk_ts::TsExpr::object(deps))
4335    };
4336    // #2355: a bodied provider whose ops construct a local agent takes a
4337    // `deps` even with no `given`, and on `workers` that `deps` carries the
4338    // Worker's `env` for the agent factory (`emit::emit_provider`).
4339    let deps_obj = if !provider.external
4340        && bynk_check::project_model::provider_constructs_agent(provider, |n| {
4341            unit_tables
4342                .get(provider_ctx)
4343                .is_some_and(|t| t.agents.contains_key(n))
4344        }) {
4345        let mut entries = match deps_obj {
4346            Some(bynk_ts::TsExpr::Object { entries, .. }) => entries,
4347            Some(other) => unreachable!("a provider's deps is an object literal: {other:?}"),
4348            None => Vec::new(),
4349        };
4350        if matches!(namespaces, ProviderNamespaces::Workers { .. }) {
4351            // Every Workers composer has the Worker's `env` in scope; leaving it
4352            // out would fault at `this.deps.env` at runtime instead.
4353            let env = env_ident.expect("a Workers composer passes its `env`");
4354            entries.push(bynk_ts::TsObjectEntry::Prop(
4355                "env".to_string(),
4356                bynk_ts::TsExpr::Ident(env.to_string()),
4357            ));
4358        }
4359        Some(bynk_ts::TsExpr::Object {
4360            entries,
4361            multiline: false,
4362        })
4363    } else {
4364        deps_obj
4365    };
4366    let mut args: Vec<bynk_ts::TsExpr> = deps_obj.into_iter().collect();
4367    // v0.18/v0.19: env-taking first-party providers (the bynk surface's
4368    // SecretsProvider; bynk.cloudflare's WorkersKv) receive the Worker `env`
4369    // explicitly — decisions 0021/0025. Keyed by (unit, class).
4370    if provider.external
4371        && bynk_check::firstparty::provider_takes_env(provider_ctx, &provider.provider_name.name)
4372        && let Some(env) = env_ident
4373    {
4374        args.push(bynk_ts::TsExpr::Ident(env.to_string()));
4375    }
4376    // Locale capability track, slice 2 (#882, Decision C): only the
4377    // `(bynk, LocaleProvider)` pair ever receives these — every other
4378    // provider's construction is unaffected since every other call site
4379    // passes `None`.
4380    if provider.external
4381        && provider_ctx == bynk_check::firstparty::BYNK_UNIT
4382        && provider.provider_name.name == "LocaleProvider"
4383        && let Some(loc) = locale_negotiation
4384    {
4385        args.push(bynk_ts::TsExpr::Ident(loc.request_expr.clone()));
4386        args.push(bynk_ts::TsExpr::Ident(loc.declared_locales_expr.clone()));
4387        args.push(bynk_ts::TsExpr::Ident(loc.reference_locale_expr.clone()));
4388    }
4389    let class = &provider.provider_name.name;
4390    // v0.17: an external (adapter) provider's class lives in the binding module,
4391    // not the adapter's interface module — instantiate it from the binding
4392    // namespace (`<adapter>__binding`, imported by the composition root).
4393    if provider.external {
4394        let built = new_call_ts_expr(&format!("{ns}__binding"), class, args);
4395        // #2137: in the test build of a `system` participant, the first-party
4396        // `Clock` reads the harness clock when the harness supplies one.
4397        if let (
4398            ProviderNamespaces::Workers {
4399                harness_clock: true,
4400            },
4401            Some(env),
4402        ) = (namespaces, env_ident)
4403            && provider_ctx == bynk_check::firstparty::BYNK_UNIT
4404            && cap == "Clock"
4405        {
4406            return harness_clock_or(env, &format!("{ns}__binding.{class}"), built);
4407        }
4408        built
4409    } else {
4410        new_call_ts_expr(&bodied_ns, class, args)
4411    }
4412}
4413
4414/// #2137: `(env as unknown as { __bynkClock?: <class> }).__bynkClock ?? <built>`
4415/// — the harness clock a `system` test puts on a participant's `env`, else
4416/// the real provider.
4417fn harness_clock_or(env: &str, class: &str, built: bynk_ts::TsExpr) -> bynk_ts::TsExpr {
4418    use bynk_ts::{TsBinaryOp, TsExpr, TsType, TsTypeMember};
4419    let slot = TsExpr::As {
4420        expr: Box::new(TsExpr::As {
4421            expr: Box::new(TsExpr::Ident(env.to_string())),
4422            ty: TsType::named("unknown"),
4423        }),
4424        ty: TsType::Object(vec![TsTypeMember::optional_prop(
4425            "__bynkClock",
4426            TsType::named(class),
4427        )]),
4428    };
4429    TsExpr::Binary {
4430        op: TsBinaryOp::NullishCoalescing,
4431        left: Box::new(TsExpr::Member {
4432            object: Box::new(slot),
4433            property: "__bynkClock".to_string(),
4434        }),
4435        right: Box::new(built),
4436    }
4437}
4438
4439/// #2137: every context a `system` suite's harness stands up as a Worker (its
4440/// participants and their event subscribers,
4441/// `test_suites::infer_harness_participants`).
4442fn harness_clock_contexts(
4443    parsed: &[ParsedFile],
4444    unit_tables: &HashMap<String, UnitTable>,
4445    unit_consumes: &HashMap<String, Vec<String>>,
4446    kinds: &BTreeMap<String, UnitKind>,
4447) -> HashSet<String> {
4448    let systems: Vec<String> = parsed
4449        .iter()
4450        .filter_map(ParsedFile::integration)
4451        .map(|d| d.target.joined())
4452        .collect();
4453    if systems.is_empty() {
4454        return HashSet::new();
4455    }
4456    let subscribers = bynk_check::symbols::discover_event_subscribers(unit_tables, unit_consumes);
4457    systems
4458        .iter()
4459        .flat_map(|t| {
4460            bynk_check::test_suites::infer_harness_participants(
4461                t,
4462                unit_consumes,
4463                kinds,
4464                &subscribers,
4465            )
4466        })
4467        .collect()
4468}
4469
4470#[allow(clippy::too_many_arguments)]
4471/// Events track, slice 0 (spine #936): does any handler in this unit emit —
4472/// the `UnitTable`-level analogue of `emitter::commons_uses_emit`, needed
4473/// here because compose works from the project-wide `UnitTable` map, not a
4474/// single unit's `TypedCommons`. #1187's slice 6 plumbing: reads the
4475/// checker's own already-resolved `Callee::Capability{cap:"Events",
4476/// op:"emit"}` (`Events.emit[...]` dispatches through the ordinary
4477/// capability-call path, `bynk-check/src/checker/calls.rs`) instead of
4478/// `emitter::block_uses_emit`'s bare-`Ident("Events")`-receiver name match.
4479/// `callees` is `None` only defensively (a unit whose own check never ran) —
4480/// every call site this function actually reaches has already certified
4481/// (review of #1202: traced live, confirmed unreachable on the build path
4482/// today). A silent `false` here disables four emission gates at once (no
4483/// fan-out DO, no `dispatchToEventsFanout` import, no `EVENTS_FANOUT`
4484/// binding, no `__eventsDispatch` field) with no diagnostic — `debug_assert`
4485/// makes that invariant enforced, not just documented, so a future caller
4486/// that violates it fails loudly in tests rather than shipping a publishing
4487/// context that silently drops every emitted event.
4488///
4489/// `emitter::block_uses_emit` — the per-*handler* twin deciding
4490/// `emit_service`/`emit_agent`'s own `deps.__eventsDispatch` *parameter*
4491/// threading — reads the same resolved `Callee` now too (its own doc
4492/// comment has the story: the two checks briefly disagreed on a
4493/// locally-shadowed `Events` type between this function converting and
4494/// that one following, confirmed by a fixture that failed `tsc --strict` in
4495/// between, `1204_events_emit_shadowed_by_local_type`), so the two stay in
4496/// agreement on every input, not just the ones existing fixtures cover.
4497pub(crate) fn unit_table_uses_emit(
4498    table: &UnitTable,
4499    callees: Option<&HashMap<ExprId, bynk_check::checker::Callee>>,
4500) -> bool {
4501    let Some(callees) = callees else {
4502        debug_assert!(
4503            false,
4504            "unit_table_uses_emit: no Callee map for a checked unit"
4505        );
4506        return false;
4507    };
4508    let mut found = false;
4509    emitter::walk_unit_table_bodies(table, &mut |e| {
4510        if !found
4511            && matches!(
4512                callees.get(&e.id),
4513                Some(bynk_check::checker::Callee::Capability { cap, op })
4514                    if cap == "Events" && op == "emit"
4515            )
4516        {
4517            found = true;
4518        }
4519    });
4520    found
4521}
4522
4523// -- Small tree-construction helpers (#1327) ------------------------------
4524//
4525// Mirrors `workers.rs`'s/`workers_entry.rs`'s/`tests_emit.rs`'s own local
4526// helper sets (#1321/#1323/#1325) — this file's own private set, not
4527// shared, matching this track's own established per-file scoping.
4528
4529fn ident(s: impl Into<String>) -> TsExpr {
4530    TsExpr::Ident(s.into())
4531}
4532
4533fn str_lit(s: impl Into<String>) -> TsExpr {
4534    TsExpr::Lit(TsLit::Str(s.into()))
4535}
4536
4537fn member(object: TsExpr, property: impl Into<String>) -> TsExpr {
4538    TsExpr::Member {
4539        object: Box::new(object),
4540        property: property.into(),
4541    }
4542}
4543
4544fn call(callee: TsExpr, args: Vec<TsExpr>) -> TsExpr {
4545    TsExpr::Call {
4546        callee: Box::new(callee),
4547        args,
4548    }
4549}
4550
4551fn method_call(object: TsExpr, method: &str, args: Vec<TsExpr>) -> TsExpr {
4552    call(member(object, method), args)
4553}
4554
4555fn const_(name: impl Into<String>, init: TsExpr) -> TsStmt {
4556    TsStmt::const_stmt(TsBindingName::Ident(name.into()), None, init, None)
4557}
4558
4559/// Sort key for a `{ns}Deps` entry, mirroring the pre-conversion code's own
4560/// `Vec<String>::sort()` over the fully rendered `"{key}: {value}"` text
4561/// rather than the bare key alone (review of #1328: `deps_entries.sort_by(|a,
4562/// b| a.0.cmp(&b.0))` is NOT equivalent — it silently reorders whenever one
4563/// key is a strict prefix of another, e.g. `Db`/`Db2`). The old text sort's
4564/// tie-break, for a key that is a strict prefix of another, was whatever byte
4565/// immediately follows it in the longer key compared against the shorter
4566/// key's own literal `':'` — since capability names are `[A-Za-z][A-Za-z0-9_
4567/// ]*`, only a digit-suffixed prefix collision (`'0'`-`'9'` are all below
4568/// `':'`, 0x3A) flips the order: `Db2: ...` sorted before `Db: ...` because
4569/// `'2'` (0x32) < `':'` (0x3A). Appending `:` to each bare key reproduces
4570/// that exact tie-break without rendering each entry's value just to sort
4571/// it — every other key relationship (no shared prefix, or a
4572/// letter/underscore-suffixed prefix, both above `':'`) is unaffected, since
4573/// the comparison never reaches the appended `:`.
4574fn deps_entry_sort_key(key: &str) -> String {
4575    format!("{key}:")
4576}
4577
4578#[allow(clippy::too_many_arguments)]
4579fn emit_composition_root(
4580    groups: &BTreeMap<String, Vec<usize>>,
4581    kinds: &BTreeMap<String, UnitKind>,
4582    unit_consumes: &HashMap<String, Vec<String>>,
4583    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
4584    unit_tables: &HashMap<String, UnitTable>,
4585    unit_callees: &HashMap<String, HashMap<ExprId, bynk_check::checker::Callee>>,
4586    // P6.x (#1232): see `EventSubscriberShape`'s own doc comment. Read by
4587    // `wants_envelope` below instead of walking a *different, already-
4588    // consumed* unit's raw `UnitTable` directly.
4589    unit_event_subscriber_shapes: &HashMap<String, HashMap<String, EventSubscriberShape>>,
4590    adapter_bindings: &HashMap<String, AdapterBinding>,
4591    unit_flattened: &HashMap<String, HashMap<String, String>>,
4592    // v0.19 (decision 0025, D1): when the program's closure reaches a
4593    // platform-native unit, composeApp takes an optional `env` and threads it
4594    // to env-taking first-party providers. A bundle on Cloudflare is a single
4595    // Worker with `env` at its entry; native-free programs emit the v0.18
4596    // no-parameter signature unchanged.
4597    thread_env: bool,
4598    // Events track, slice 0 (spine #936): the project-wide subscriber table,
4599    // computed once by the caller (Workers mode needs the same table for its
4600    // own per-Worker fan-out wiring, so it is shared rather than rebuilt).
4601    event_subscribers: &BTreeMap<(String, String), Vec<(String, String)>>,
4602) -> Option<TsProgram> {
4603    // Identify contexts that consume something whose surface has services.
4604    let mut needs_compose = false;
4605    for (name, targets) in unit_consumes {
4606        if !targets.is_empty()
4607            && let Some(UnitKind::Context) = kinds.get(name)
4608        {
4609            for t in targets {
4610                if let Some(other) = unit_tables.get(t)
4611                    && !other.services.is_empty()
4612                {
4613                    needs_compose = true;
4614                }
4615            }
4616        }
4617    }
4618    // v0.15: also compose when a context uses a consumed context's capability
4619    // (in a handler or in a provider's `given`) — the consumer must instantiate
4620    // the provided capability's provider locally.
4621    if !needs_compose {
4622        for (name, kind) in kinds {
4623            if *kind != UnitKind::Context {
4624                continue;
4625            }
4626            let Some(table) = unit_tables.get(name) else {
4627                continue;
4628            };
4629            let consumed = unit_consumes.get(name).cloned().unwrap_or_default();
4630            let aliases = unit_consumes_aliases.get(name).cloned().unwrap_or_default();
4631            let flattened = unit_flattened.get(name).cloned().unwrap_or_default();
4632            if !handler_cross_caps(table, &consumed, &aliases, &flattened).is_empty()
4633                || table.providers.values().any(|p| {
4634                    p.given.iter().any(|g| {
4635                        g.is_cross_context()
4636                            // v0.18: a bare given flattened from `consumes U
4637                            // { Cap }` is cross-unit too — its provider lives
4638                            // in the consumed unit.
4639                            || (g.prefix().is_none() && flattened.contains_key(g.key()))
4640                    })
4641                })
4642                // Events track, slice 0 (spine #936): a context whose
4643                // handlers emit needs its own `__eventsDispatch` closure
4644                // built by compose (§ `discover_event_subscribers`) even
4645                // when it consumes nothing and no other context consumes
4646                // it — `Events` is filtered out of `handler_cross_caps`
4647                // (there is no `EventsProvider`), so without this check a
4648                // publish-only context would never get a compose entry and
4649                // its service would simply never be called.
4650                || unit_table_uses_emit(table, unit_callees.get(name))
4651                // S4a (#1966): a derived capability's instance is its base's
4652                // platform provider, built by compose like a flattened one.
4653                || !table.derived.is_empty()
4654            {
4655                needs_compose = true;
4656                break;
4657            }
4658        }
4659    }
4660    if !needs_compose {
4661        return None;
4662    }
4663
4664    let mut contexts: Vec<&String> = groups
4665        .keys()
4666        .filter(|n| kinds.get(*n) == Some(&UnitKind::Context))
4667        .collect();
4668    contexts.sort();
4669
4670    // The composeApp body is built first so the provider expressions can
4671    // record every unit namespace they reference (v0.18: an external
4672    // provider's `given` may pull in *another* adapter's binding — the
4673    // transitive given-closure — which must then be imported).
4674    let mut referenced_units: BTreeSet<String> = BTreeSet::new();
4675
4676    let compose_params: Vec<TsParam> = if thread_env {
4677        vec![TsParam {
4678            name: "env".to_string(),
4679            ty: Some(TsType::named("unknown")),
4680            optional: true,
4681        }]
4682    } else {
4683        Vec::new()
4684    };
4685    let env_ident = if thread_env { Some("env") } else { None };
4686
4687    // Build each context's deps and surface in dependency-respecting order:
4688    // a context that consumes another must come after the consumed context,
4689    // so its `surface` field can reference the already-built surface.
4690    let mut ordered: Vec<String> = Vec::new();
4691    let mut visited: HashSet<String> = HashSet::new();
4692    fn visit(
4693        node: &str,
4694        unit_consumes: &HashMap<String, Vec<String>>,
4695        visited: &mut HashSet<String>,
4696        out: &mut Vec<String>,
4697    ) {
4698        if visited.contains(node) {
4699            return;
4700        }
4701        visited.insert(node.to_string());
4702        if let Some(targets) = unit_consumes.get(node) {
4703            for t in targets {
4704                visit(t, unit_consumes, visited, out);
4705            }
4706        }
4707        out.push(node.to_string());
4708    }
4709    for c in &contexts {
4710        visit(c, unit_consumes, &mut visited, &mut ordered);
4711    }
4712
4713    let mut body: Vec<TsStmt> = Vec::new();
4714
4715    for ctx_name in &ordered {
4716        if kinds.get(ctx_name.as_str()) != Some(&UnitKind::Context) {
4717            continue;
4718        }
4719        let Some(table) = unit_tables.get(ctx_name.as_str()) else {
4720            continue;
4721        };
4722        // A context's deps object exists only to feed its `__makeSurface`; a
4723        // capability-only context (no services) needs neither (v0.15).
4724        if table.services.is_empty() {
4725            continue;
4726        }
4727        let ns = ctx_name.replace('.', "_");
4728
4729        let mut deps_entries: Vec<(String, TsExpr)> = table
4730            .providers
4731            .keys()
4732            .map(|cap| {
4733                let expr = instantiate_provider_ts_expr(
4734                    ctx_name,
4735                    cap,
4736                    unit_tables,
4737                    unit_consumes,
4738                    unit_consumes_aliases,
4739                    unit_flattened,
4740                    ProviderNamespaces::Bundle,
4741                    env_ident,
4742                    None, // Bundle mode has no inbound request (Decision A)
4743                    &mut referenced_units,
4744                );
4745                (cap.clone(), expr)
4746            })
4747            .collect();
4748        // v0.15: cross-context capabilities used directly by handlers become
4749        // top-level deps fields, instantiated from the providing context.
4750        {
4751            let consumed = unit_consumes
4752                .get(ctx_name.as_str())
4753                .cloned()
4754                .unwrap_or_default();
4755            let aliases = unit_consumes_aliases
4756                .get(ctx_name.as_str())
4757                .cloned()
4758                .unwrap_or_default();
4759            let flattened = unit_flattened
4760                .get(ctx_name.as_str())
4761                .cloned()
4762                .unwrap_or_default();
4763            for (key, cctx) in handler_cross_caps(table, &consumed, &aliases, &flattened) {
4764                let expr = instantiate_provider_ts_expr(
4765                    &cctx,
4766                    &key,
4767                    unit_tables,
4768                    unit_consumes,
4769                    unit_consumes_aliases,
4770                    unit_flattened,
4771                    ProviderNamespaces::Bundle,
4772                    env_ident,
4773                    None, // Bundle mode has no inbound request (Decision A)
4774                    &mut referenced_units,
4775                );
4776                deps_entries.push((key, expr));
4777            }
4778        }
4779        // S4a (#1966): each derived capability's instance.
4780        let mut derived: Vec<&String> = table.derived.keys().collect();
4781        derived.sort();
4782        for cap in derived {
4783            let expr = instantiate_provider_ts_expr(
4784                ctx_name,
4785                cap,
4786                unit_tables,
4787                unit_consumes,
4788                unit_consumes_aliases,
4789                unit_flattened,
4790                ProviderNamespaces::Bundle,
4791                env_ident,
4792                None,
4793                &mut referenced_units,
4794            );
4795            deps_entries.push((cap.clone(), expr));
4796        }
4797        // Events track, slice 0 (spine #936): a context whose handlers emit
4798        // gets an `__eventsDispatch` closure built here — Bundle/node mode
4799        // has no isolate boundary to cross, so this dispatches in-process
4800        // directly into each subscriber's own `on event` handler (`.event`,
4801        // the object method `emit_service` gives `HandlerKind::Event`),
4802        // reusing whatever deps that subscriber context already builds in
4803        // this same loop (referenced by name; the arrow function body isn't
4804        // evaluated until well after every `const ...Deps` in `composeApp`
4805        // has run, so declaration order here doesn't matter). A publisher
4806        // with no subscribers still gets the field — its type is required —
4807        // just with an empty switch.
4808        if unit_table_uses_emit(table, unit_callees.get(ctx_name)) {
4809            let mut cases = String::new();
4810            for name in table.events.keys() {
4811                let Some(subs) = event_subscribers.get(&(ctx_name.clone(), name.clone())) else {
4812                    continue;
4813                };
4814                // ADR 0284: subscriber failure isolation — one subscriber's
4815                // throw is caught and logged, not left to abort delivery to
4816                // its siblings or propagate into the already-committed
4817                // publishing handler. Mirrors the Cloudflare fan-out DO's own
4818                // per-subscriber try/catch (`emit_events_fanout_do`) so the
4819                // two targets agree on this guarantee, not just on delivery.
4820                let calls: Vec<String> = subs
4821                    .iter()
4822                    .map(|(sub_ctx, sub_svc)| {
4823                        let sub_ns = sub_ctx.replace('.', "_");
4824                        // Events track, slice 2 (spine #936): the envelope
4825                        // is only forwarded to a subscriber that declared
4826                        // the optional second `env: EventEnvelope`
4827                        // parameter — a subscriber that kept `on event(e:
4828                        // E)` sees no change to its call at all. Slice 4
4829                        // (#985): also forwarded when the subscriber's
4830                        // protocol carries a `via schema(N)` clause, even if
4831                        // undeclared — `emit_service` inserts a synthetic
4832                        // `env` parameter in that case, and needs the value
4833                        // to line up positionally.
4834                        let wants_envelope = unit_event_subscriber_shapes
4835                            .get(sub_ctx)
4836                            .and_then(|m| m.get(sub_svc))
4837                            .is_some_and(|shape| shape.two_param_handler || shape.schema_dispatch);
4838                        // P7.2: deferred, not narrowed — a first attempt used the
4839                        // event's own bare name directly (`EventDecl::as_type_decl`
4840                        // keys the synthetic `TypeDecl` on it), on the theory that
4841                        // an event doubles as a named type. It broke real
4842                        // `tsc --strict` fixtures ("Cannot find name") — the bare
4843                        // name isn't necessarily in scope at this dispatch site
4844                        // (cross-context: publisher and subscriber are different
4845                        // units), the same qualification problem
4846                        // `ts_type_ref_qualified_ts_type` exists to solve for
4847                        // handler wrappers elsewhere in this crate. Needs the same
4848                        // kind of scoped qualification, not a bare name — more
4849                        // than a same-line fix.
4850                        let call_args = if wants_envelope {
4851                            "ev.payload as any, ev.envelope".to_string()
4852                        } else {
4853                            "ev.payload as any".to_string()
4854                        };
4855                        format!(
4856                            "try {{ await {sub_ns}.{sub_svc}.event({call_args}, {sub_ns}Deps); }} catch (e) {{ globalThis.console.error(\"EventsFanout delivery failed\", {{ event: ev.type, service: {sub_svc:?}, error: String(e) }}); }}"
4857                        )
4858                    })
4859                    .collect();
4860                cases.push_str(&format!("case {name:?}: {{ {} break; }} ", calls.join(" ")));
4861            }
4862            // Decision B (#1327): the closure's own body is a genuine block
4863            // statement (`for`/`switch`/`try`-`catch` nested), not an
4864            // expression. #1435 later gave `TsExpr::Arrow` a real block-body
4865            // shape (`TsArrowBody::Block`) — but that variant's own printer
4866            // reuses the compact-statement-list renderer `TsStmtKind::
4867            // InlineBlock` already shares (one physical line, semicolon-
4868            // separated top-level statements), which cannot flatten a nested
4869            // `for`/`switch` onto one line the way this closure's own real
4870            // content needs; new "flatten every nested statement to one
4871            // line" printer machinery for that would still be disproportionate
4872            // to what the 3 real fixtures reaching this closure need. Kept as
4873            // the same nested `format!` calls as before conversion, just fed
4874            // into a real `Arrow` node's `body` as one opaque `TsExpr::Ident`
4875            // — the same "opaque text carrier" precedent `workers.rs`'s/
4876            // `workers_entry.rs`'s own `claim_predicate_to_js` output already
4877            // uses.
4878            let dispatch_body =
4879                format!("{{ for (const ev of events) {{ switch (ev.type) {{ {cases}}} }} }}");
4880            let arrow = TsExpr::Arrow {
4881                params: vec![TsParam {
4882                    name: "events".to_string(),
4883                    ty: Some(TsType::named_with_args(
4884                        "globalThis.Array",
4885                        vec![TsType::named(crate::emitter::EVENTS_WIRE_EVENT_TS_TYPE)],
4886                    )),
4887                    optional: false,
4888                }],
4889                is_async: true,
4890                generics: Vec::new(),
4891                return_type: None,
4892                body: Box::new(bynk_ts::TsArrowBody::Expr(Box::new(ident(dispatch_body)))),
4893            };
4894            deps_entries.push(("__eventsDispatch".to_string(), arrow));
4895        }
4896        deps_entries.sort_by_cached_key(|(k, _)| deps_entry_sort_key(k));
4897
4898        let mut surface_entries: Vec<(String, TsExpr)> = Vec::new();
4899        if let Some(targets) = unit_consumes.get(ctx_name.as_str()) {
4900            let aliases = unit_consumes_aliases
4901                .get(ctx_name.as_str())
4902                .cloned()
4903                .unwrap_or_default();
4904            let mut alias_for: HashMap<String, String> = HashMap::new();
4905            for (alias, target) in &aliases {
4906                alias_for.insert(target.clone(), alias.clone());
4907            }
4908            let mut sorted_targets = targets.clone();
4909            sorted_targets.sort();
4910            for t in &sorted_targets {
4911                let Some(other) = unit_tables.get(t) else {
4912                    continue;
4913                };
4914                if other.services.is_empty() {
4915                    continue;
4916                }
4917                let surface_key = alias_for
4918                    .get(t)
4919                    .cloned()
4920                    .unwrap_or_else(|| t.rsplit('.').next().unwrap_or(t.as_str()).to_string());
4921                let t_ns = t.replace('.', "_");
4922                // v0.54 (#655): a consumed context with an `on call … by c: Caller`
4923                // handler needs the *caller's* qualified name (this context) threaded
4924                // into that handler's deps as its `CallerId` identity (ADR 0092). The
4925                // shared `{t_ns}Surface` (built for the top-level entry with the
4926                // provider's own name) would carry the wrong caller, so build a
4927                // per-consumer surface instead. A caller-free provider keeps the
4928                // shared instance — byte-unchanged.
4929                let entry = if context_binds_caller(other) {
4930                    method_call(
4931                        ident(t_ns.clone()),
4932                        "__makeSurface",
4933                        vec![ident(format!("{t_ns}Deps")), str_lit(ctx_name.as_str())],
4934                    )
4935                } else {
4936                    ident(format!("{t_ns}Surface"))
4937                };
4938                surface_entries.push((surface_key, entry));
4939            }
4940        }
4941        if !surface_entries.is_empty() {
4942            deps_entries.push(("surface".to_string(), TsExpr::object(surface_entries)));
4943        }
4944        // #1327: the pre-conversion `format!("  const {ns}Deps = {{ {} }};",
4945        // deps_entries.join(", "))` template always has a space on each side
4946        // of its `{}` slot — with zero entries that literally produces
4947        // `"{  }"` (a *double* space), not the tight `"{}"` the ordinary
4948        // single-line `TsExpr::Object` empty-entries shortcut renders — the
4949        // same real, reachable quirk `workers.rs`'s own conversion (#1321)
4950        // found and carried for its own `deps` object, reachable here too (a
4951        // services-having context with no providers, no cross-caps, no
4952        // emit, and no consumed-service surface — `98_cross_context_call_
4953        // with_alias` and 6 other real fixtures hit exactly this).
4954        let deps_init = if deps_entries.is_empty() {
4955            ident("{  }")
4956        } else {
4957            TsExpr::object(deps_entries)
4958        };
4959        body.push(const_(format!("{ns}Deps"), deps_init));
4960        if !table.services.is_empty() {
4961            // The top-level entry addresses the context directly; there is no
4962            // calling context, so a `by c: Caller` handler reached this way reads
4963            // the context's own qualified name (a stable, non-empty `CallerId`
4964            // within the single-trust-domain bundle).
4965            let mut make_surface_args = vec![ident(format!("{ns}Deps"))];
4966            if context_binds_caller(table) {
4967                make_surface_args.push(str_lit(ctx_name.as_str()));
4968            }
4969            body.push(const_(
4970                format!("{ns}Surface"),
4971                method_call(ident(ns.clone()), "__makeSurface", make_surface_args),
4972            ));
4973        }
4974    }
4975
4976    // #1327: the pre-conversion code unconditionally wrote one blank line
4977    // between the last `const ...Deps`/`const ...Surface` and the `return`
4978    // (`out.push('\n')`, run once regardless of how many contexts the loop
4979    // above actually pushed) — `TsStmtKind::Blank` (#1323) is the tree's own
4980    // equivalent, needed here since the printer's own "blank line between
4981    // top-level declarations" policy only separates entries in
4982    // `TsProgram.stmts` itself, not statements inside one function body.
4983    body.push(TsStmt::blank(None));
4984
4985    // Export per-context surfaces under a top-level object.
4986    let mut return_entries: Vec<(String, TsExpr)> = Vec::new();
4987    for ctx_name in &contexts {
4988        let Some(table) = unit_tables.get(ctx_name.as_str()) else {
4989            continue;
4990        };
4991        if table.services.is_empty() {
4992            continue;
4993        }
4994        let ns = ctx_name.replace('.', "_");
4995        let key = ctx_name.rsplit('.').next().unwrap_or(ctx_name.as_str());
4996        return_entries.push((key.to_string(), ident(format!("{ns}Surface"))));
4997    }
4998    body.push(TsStmt::return_stmt(
4999        Some(TsExpr::multiline_object(return_entries)),
5000        None,
5001    ));
5002
5003    // Assemble the header now that the body has recorded which units its
5004    // provider expressions reference.
5005    let mut program = TsProgram::new();
5006    program.push(TsStmt::comment(
5007        "Generated by bynkc — do not edit by hand.",
5008        None,
5009    ));
5010    program.push(TsStmt::comment("composition root", None));
5011
5012    // Import every context as a namespace.
5013    for ctx_name in &contexts {
5014        let dir = emitter::ts_specifier(&commons_dir_for(ctx_name));
5015        let ns = ctx_name.replace('.', "_");
5016        program.push(TsStmt::decl(
5017            TsDecl::ImportNamespace {
5018                type_only: false,
5019                alias: ns,
5020                from: format!("./{dir}.js"),
5021            },
5022            None,
5023        ));
5024    }
5025    // v0.17: import each consumed adapter's binding module — the external
5026    // provider classes live there, not in the adapter's interface module.
5027    // v0.18: plus every adapter the provider expressions referenced through
5028    // the transitive given-closure (an adapter's external provider may depend
5029    // on another adapter's capability, spec §4.5).
5030    let mut consumed_adapters: Vec<String> = unit_consumes
5031        .iter()
5032        .filter(|(name, _)| kinds.get(*name) == Some(&UnitKind::Context))
5033        .flat_map(|(_, targets)| targets.iter().cloned())
5034        .chain(referenced_units.iter().cloned())
5035        .filter(|t| adapter_bindings.contains_key(t))
5036        .collect();
5037    consumed_adapters.sort();
5038    consumed_adapters.dedup();
5039    for adapter in &consumed_adapters {
5040        let ns = adapter.replace('.', "_");
5041        let module =
5042            emitter::ts_specifier(&adapter_bindings[adapter].output_path.with_extension("js"));
5043        program.push(TsStmt::decl(
5044            TsDecl::ImportNamespace {
5045                type_only: false,
5046                alias: format!("{ns}__binding"),
5047                from: format!("./{module}"),
5048            },
5049            None,
5050        ));
5051    }
5052
5053    program.push(TsStmt::decl(
5054        TsDecl::Export(Box::new(TsDecl::Function {
5055            name: "composeApp".to_string(),
5056            generics: Vec::new(),
5057            params: compose_params,
5058            return_type: None,
5059            body,
5060            is_async: false,
5061            inline: false,
5062        })),
5063        None,
5064    ));
5065
5066    Some(program)
5067}
5068
5069// -- internals --
5070
5071/// Context passed to the emitter so it can resolve cross-file and
5072/// cross-unit references into TypeScript import statements.
5073/// The schema registry's reconciled versions, threaded from `run_checks` to
5074/// each unit's emission: events keyed `<unit>.<Event>` (#980), agents keyed
5075/// `<unit>.<Agent>` (state-migrations S1, #2211). Both empty when the build
5076/// has no lock.
5077#[derive(Debug, Default)]
5078pub(crate) struct SchemaVersions {
5079    pub events: HashMap<String, i64>,
5080    pub agents: HashMap<String, bynk_check::schema_registry::AgentVersions>,
5081}
5082
5083pub(crate) struct EmitProjectCtx {
5084    /// Source path of the file being emitted (relative to project root).
5085    pub source_path: PathBuf,
5086    /// The file's project-relative identity path, as the test runner reports it in
5087    /// an `expect`'s location. A violated declaration's location is built from it (#2272).
5088    pub identity_path: String,
5089    /// Joined name of the commons or context this file belongs to.
5090    pub commons_name: String,
5091    /// Which file declares each name in the local unit.
5092    pub file_decl_index: FileDeclIndex,
5093    /// For each imported name, the joined name of the unit it came from.
5094    pub imported_from: HashMap<String, String>,
5095    /// For each imported name, the kind (commons vs context) of the source unit.
5096    pub imported_from_kind: HashMap<String, UnitKind>,
5097    /// For each imported unit, the file path that declares each name.
5098    pub imported_decl_paths: HashMap<String, HashMap<String, PathBuf>>,
5099    /// What kind of unit this is.
5100    pub unit_kind: UnitKind,
5101    /// For contexts: this context's qualified name (used as the brand for
5102    /// rebranded mixed-in types and exported types).
5103    pub owning_context: Option<String>,
5104    /// For contexts: exports of each consumed context (so the emitter knows
5105    /// which names to import and how).
5106    pub exports_for_consumed: HashMap<String, HashMap<String, Visibility>>,
5107    /// For contexts: full cross-context information (consumed contexts,
5108    /// aliases, consumed services and types). Mirrors what the resolver
5109    /// and checker see (v0.6).
5110    pub cross_context: resolver::CrossContextInfo,
5111    /// v0.8 build target. Workers mode reroutes cross-context calls through
5112    /// Service Bindings and adds per-Worker entry/composition artefacts.
5113    pub target: BuildTarget,
5114    /// Agent names declared in this unit. The body lowering uses this set
5115    /// to recognise `Agent(key)` construction and `agent_instance.method(...)`
5116    /// dispatch.
5117    pub local_agents: HashSet<String>,
5118    /// #527: for each local agent with `given` capabilities, the TS
5119    /// expression building those deps DO-side (workers contexts only; the DO
5120    /// wire cannot carry providers). Consumed by `emit_agent`'s fetch branch.
5121    pub agent_given_deps: HashMap<String, String>,
5122    /// #527: extra import lines `handlers.ts` needs for the expressions above.
5123    pub extra_import_lines: Vec<String>,
5124    /// #527: for each local agent, each `on call` method's `given` capability
5125    /// list. The lowering records which agent methods a handler body calls so
5126    /// the handler's emitted deps *type* carries the callee's capabilities —
5127    /// the runtime deps value (built by compose) always did.
5128    pub agent_method_givens: HashMap<String, HashMap<String, Vec<CapRefIr>>>,
5129    /// #1961: the consumed contexts the context's agents call
5130    /// ([`emitter::agent_cross_context_targets`]), unit-wide. A handler that
5131    /// constructs an agent names them in its `deps.env` type.
5132    pub agent_called_contexts: Vec<String>,
5133    /// v0.47: the context's actor declarations (merged across files), keyed by
5134    /// name. Used to resolve a handler's Bearer verification seam in `emit.rs`
5135    /// regardless of which file declares the actor.
5136    pub actors: HashMap<String, ActorDecl>,
5137    /// Events slice 3b (#978): each locally-declared event's resolved
5138    /// `@schema(N)` version (or `1` if absent), merged across files the same
5139    /// way `actors` is above — `Events.emit[E]`'s lowering site only has
5140    /// `E`'s bare name (the turbofish type argument), never its declaration,
5141    /// so this is threaded down to `ModuleCtx`/`LowerCtx` rather than
5142    /// re-derived from the per-file synthetic `Commons` `lower.rs` otherwise
5143    /// sees (which would silently miss an event declared in a sibling file).
5144    pub event_schema_versions: HashMap<String, i64>,
5145    /// State-migrations S1 (#2211): each locally-declared agent's lock
5146    /// `version` and oldest readable stored version, keyed by agent name.
5147    /// Empty when the build has no lock (`SchemaLock::Off`), in which case the
5148    /// agent emits no version logic at all (Decision A).
5149    pub agent_schema_versions: HashMap<String, bynk_check::schema_registry::AgentVersions>,
5150    /// v0.17: consumed unit names that are adapters. An adapter is not a Worker,
5151    /// so in workers mode its capability types are imported from its root module
5152    /// (`<adapter>.ts`), not from a per-Worker `handlers.ts`.
5153    pub consumed_adapters: HashSet<String>,
5154    /// #2045: the types of consumed *contexts* (adapters aside) that carry
5155    /// attached methods. A call to one (`b.big()` lowers to `Batch.big(b)`) runs
5156    /// the callee's own code from its companion object, so that name is a real
5157    /// value import; every other consumed context type imports type-only.
5158    pub consumed_types_with_methods: HashSet<String>,
5159    /// Slice 2: the extension emitted import specifiers use (`.js` default; `.ts`
5160    /// for the `bynkc test --inspect` debug build). Consulted by `runtime_import_for`
5161    /// and the sibling/cross-commons specifier helpers.
5162    pub import_ext: ImportExt,
5163    /// v0.115 (testing track slice 3): emit the function-contract call-site guard
5164    /// (dev/test profile). Stripped in the deploy build for zero runtime cost.
5165    pub contracts: bool,
5166    /// v0.119 (testing track slice 7, ADR 0155): agent names a `for all run:
5167    /// History[Agent]` property in this project drives. Only these agents gain the
5168    /// exported `__bynkDriveHistory_<Agent>` test-support driver — every other
5169    /// agent's emission is byte-for-byte unchanged.
5170    pub history_target_agents: HashSet<String>,
5171    /// #1883, slice 1b: each declared asset's served URL path, keyed by its
5172    /// normalised source path (`assets::declared_served_paths`) — the value
5173    /// of the asset's `.url`.
5174    pub asset_served: BTreeMap<String, String>,
5175    /// #2387, slice 2: each declared embed's text, keyed by its normalised
5176    /// source path (`embeds::read_declared_embeds`) — the value of the
5177    /// embed's constant.
5178    pub embed_text: BTreeMap<String, String>,
5179    /// v0.132.1 (#481): for a context, the user-defined attached methods of each
5180    /// `uses`-imported refined/opaque type, keyed by the type's name and sorted
5181    /// by method name. The context's own `TypedCommons` merges the imported
5182    /// *types* but not their fn items, so `emit_context_rebrands` reads this to
5183    /// forward `Cents.fromInt(…)` and friends onto the rebranded const. Empty
5184    /// for commons units and for contexts with no such imports.
5185    ///
5186    /// P6.18: each entry is a resolved [`FnSig`] (the declaring unit's own
5187    /// `params`/`return_ty`, already `TyId`-typed), not a raw `FnDecl` —
5188    /// see [`build_emit_unit_ctx`]'s own doc comment for why.
5189    pub imported_methods: HashMap<String, Vec<FnSig>>,
5190    /// Which conditional `runtime.ts` helpers this file's emission referenced.
5191    ///
5192    /// Unlike every field above, this is an **output**, not an input: emission
5193    /// writes it (through `&self`, via interior mutability) and the header /
5194    /// import post-pass reads it back. It rides on the context because the
5195    /// producers — the `Bytes` kernel in `lower`, the boundary codecs in
5196    /// `serialisation`, the ICU formatters in `emit` — already receive `&ctx`,
5197    /// so no other signature has to change to carry the fact up.
5198    ///
5199    /// One `EmitProjectCtx` is built per emitted file, immediately before its
5200    /// `emit_project` call, so the flags cannot leak between files. Replaces a
5201    /// substring scan of the generated text; see `emitter::runtime_use`.
5202    pub runtime_use: crate::emitter::RuntimeUse,
5203}
5204
5205impl EmitProjectCtx {
5206    /// #2045: on `workers`, the types this unit imports from a consumed
5207    /// *context* (not an adapter): each is another Worker's, so this module
5208    /// names it in type position only and builds its sum variants inline.
5209    pub(crate) fn consumed_context_types(&self) -> HashSet<String> {
5210        if self.target != BuildTarget::Workers || self.unit_kind != UnitKind::Context {
5211            return HashSet::new();
5212        }
5213        self.imported_from
5214            .iter()
5215            .filter(|(n, owner)| {
5216                self.imported_from_kind.get(n.as_str()) == Some(&UnitKind::Context)
5217                    && !self.consumed_adapters.contains(owner.as_str())
5218            })
5219            .map(|(n, _)| n.clone())
5220            .collect()
5221    }
5222
5223    pub fn commons_path(name: &str) -> PathBuf {
5224        commons_dir_for(name)
5225    }
5226}
5227
5228#[allow(dead_code)]
5229fn _ensure_components_used(_p: &Path) {
5230    let _ = Component::CurDir;
5231}
5232
5233/// v0.177 (#643, review of #658): the cross-context services a context actually
5234/// **calls**, as `consumed context → service names`.
5235///
5236/// This is not the same as "every service the dependency provides", and the
5237/// difference is the difference between a gate that reports what it *knows* is
5238/// skewed and one that reports what merely *differs*. If `payment` provides
5239/// `authorise` and `refund`, `orders` calls only `authorise`, and `refund`'s
5240/// contract changed, then recording `refund` in `orders`'s `expects` would refuse
5241/// `deploy --context orders` over a service `orders` never touches and whose
5242/// runtime check could never fire. ADR 0200 Decision E rejects a per-*context*
5243/// hash for exactly this reason — that it becomes a deployment tax — and a
5244/// per-context *gate* over per-service hashes would reintroduce it one layer up.
5245///
5246/// So the manifest's `expects` mirrors the runtime check's granularity: one entry
5247/// per call site, discovered the same way the lowering discovers it — an ident
5248/// chain on the receiver that resolves to a consumed context.
5249fn called_cross_context_services(
5250    table: &UnitTable,
5251    consumed: &[String],
5252    // #1187's slice 6 plumbing: reads the checker's own already-resolved
5253    // `Callee::Cross { unit, service }` (`RunChecks::Checked::unit_callees`'s
5254    // own doc comment has the full grounding) instead of re-deriving
5255    // cross-context-ness by flattening a receiver's own ident chain and
5256    // string-matching it against `consumed`/`aliases` — the identical
5257    // resolution `CrossContextInfo::resolve_prefix` already did once, at
5258    // check time, per call site. `consumed` stays, purely as the cheap
5259    // early-out below: an empty `consumes` list means no `Callee::Cross`
5260    // could exist in this unit's own bodies regardless, so skip the walk.
5261    callees: Option<&HashMap<ExprId, bynk_check::checker::Callee>>,
5262) -> std::collections::BTreeMap<String, std::collections::BTreeSet<String>> {
5263    let mut out: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
5264        std::collections::BTreeMap::new();
5265    if consumed.is_empty() {
5266        return out;
5267    }
5268    // See `unit_table_uses_emit`'s own matching `debug_assert` (review of
5269    // #1202) — `consumed` non-empty means this unit certified with a real
5270    // `consumes`, so `callees` missing here is the same "invariant broke a
5271    // thousand lines away" case, just silently thinning the contracts
5272    // manifest's `expects` instead of silently disabling emission.
5273    let Some(callees) = callees else {
5274        debug_assert!(
5275            false,
5276            "called_cross_context_services: no Callee map for a checked unit with a non-empty \
5277             consumes list"
5278        );
5279        return out;
5280    };
5281    emitter::walk_unit_table_bodies(table, &mut |e| {
5282        if let Some(bynk_check::checker::Callee::Cross { unit, service }) = callees.get(&e.id) {
5283            out.entry(unit.clone()).or_default().insert(service.clone());
5284        }
5285    });
5286    out
5287}
5288
5289#[cfg(test)]
5290mod tests {
5291    use super::*;
5292    use std::fs;
5293
5294    /// Review of #1904: the `_headers` glob must cover every served path, or
5295    /// every asset silently loses its immutable cache lifetime. Both come
5296    /// from `SERVED_PREFIX`; this pins that they still agree.
5297    #[test]
5298    fn the_headers_glob_covers_every_served_path() {
5299        let headers = crate::emitter::wrangler::asset_headers();
5300        let glob = headers.lines().next().expect("a path line");
5301        let prefix = glob.strip_suffix('*').expect("a prefix glob");
5302        for source in ["assets/board.css", "assets/css/a.min.js", "assets/LICENSE"] {
5303            let served = bynk_check::assets::served_path(source, b"x");
5304            assert!(served.starts_with(prefix), "{served} not under {glob}");
5305            let staged = worker_assets_dir("w").join(served.trim_start_matches('/'));
5306            assert!(
5307                is_worker_asset_path(&staged),
5308                "{} is owned",
5309                staged.display()
5310            );
5311        }
5312    }
5313
5314    /// #1883, slice 0: the ownership rule `write_output`'s reconciliation
5315    /// prunes by. Only files *inside* a Worker's assets directory qualify —
5316    /// not the directory itself, not a same-named directory at another depth,
5317    /// and not a sibling of it in the Worker's own output.
5318    #[test]
5319    fn is_worker_asset_path_matches_only_files_inside_a_worker_assets_dir() {
5320        let owned = [
5321            "workers/compat-board/assets/_headers",
5322            "workers/compat-board/assets/_assets/board.3f9c2a.css",
5323        ];
5324        for p in owned {
5325            assert!(is_worker_asset_path(Path::new(p)), "{p} is owned");
5326        }
5327        let not_owned = [
5328            "workers/compat-board/assets",
5329            "workers/compat-board/wrangler.toml",
5330            "workers/compat-board/notes.css",
5331            "workers/assets/x.css",
5332            "assets/x.css",
5333            "compat/assets/x.css",
5334            "workers/compat-board/sub/assets/x.css",
5335            // Review of #1902: `components()` keeps `..`, and `dir.join`
5336            // would then write outside the output root entirely.
5337            "workers/compat-board/assets/../../../../etc/cron.d/x",
5338            "workers/compat-board/assets/_assets/../x.css",
5339            "workers/compat-board/assets/..",
5340        ];
5341        for p in not_owned {
5342            assert!(!is_worker_asset_path(Path::new(p)), "{p} is not owned");
5343        }
5344        assert_eq!(
5345            worker_assets_dir("compat-board").join("_headers"),
5346            PathBuf::from(owned[0]),
5347            "the producer's directory and the pruner's rule agree"
5348        );
5349    }
5350
5351    /// Review of #1328: the pre-conversion code sorted the fully rendered
5352    /// `"{key}: {value}"` text, so a key that is a strict prefix of another
5353    /// (`Db`/`Db2`) sorted by the shorter key's own `':'` losing to the
5354    /// longer key's next byte, a digit — `Db2: ...` sorted before `Db:
5355    /// ...`. A bare-key sort (`"Db" < "Db2"`) gets this backwards.
5356    /// `deps_entry_sort_key` must reproduce the original order exactly.
5357    #[test]
5358    fn deps_entry_sort_key_reproduces_the_pre_conversion_full_text_sort_order() {
5359        let mut keys = vec!["Db2".to_string(), "Db".to_string()];
5360        keys.sort_by_cached_key(|k| deps_entry_sort_key(k));
5361        assert_eq!(
5362            keys,
5363            vec!["Db2".to_string(), "Db".to_string()],
5364            "Db2 must sort before Db, matching the old full-text sort"
5365        );
5366
5367        // A letter/underscore-suffixed prefix collision is unaffected —
5368        // matches plain key ordering both before and after this fix.
5369        let mut keys = vec!["Db_pool".to_string(), "Db".to_string()];
5370        keys.sort_by_cached_key(|k| deps_entry_sort_key(k));
5371        assert_eq!(keys, vec!["Db".to_string(), "Db_pool".to_string()]);
5372
5373        // No shared prefix at all: ordinary alphabetical order, unaffected.
5374        let mut keys = vec!["Queue".to_string(), "Cache".to_string()];
5375        keys.sort_by_cached_key(|k| deps_entry_sort_key(k));
5376        assert_eq!(keys, vec!["Cache".to_string(), "Queue".to_string()]);
5377    }
5378
5379    /// Regression (code review of #1114): a `sources` key that matches none
5380    /// of `trees`'s roots (shouldn't happen for a well-formed map — see
5381    /// `sources_to_discovered`'s own doc) must fall back to the *last* tree,
5382    /// matching the pre-R3.9 two-tree `partition`'s fallback, not silently
5383    /// switch to the first.
5384    #[test]
5385    fn sources_to_discovered_unmatched_key_falls_back_to_the_last_tree() {
5386        let trees = vec![
5387            (PathBuf::from("/proj/src"), PathBuf::from("src")),
5388            (PathBuf::from("/proj/tests"), PathBuf::from("tests")),
5389        ];
5390        let mut sources = HashMap::new();
5391        sources.insert(PathBuf::from("/proj/src/a.bynk"), "commons a\n".to_string());
5392        sources.insert(
5393            PathBuf::from("/elsewhere/stray.bynk"),
5394            "commons stray\n".to_string(),
5395        );
5396        let (_, discovered) = sources_to_discovered(&sources, &trees);
5397        let buckets = discovered.expect("a sources map always yields Some(Discovered)");
5398        assert_eq!(buckets[0], vec![PathBuf::from("/proj/src/a.bynk")]);
5399        assert_eq!(
5400            buckets[1],
5401            vec![PathBuf::from("/elsewhere/stray.bynk")],
5402            "an unmatched key must land in the last tree, not the first"
5403        );
5404    }
5405
5406    /// Content-ownership track (#1086) slice 4: this crate's own
5407    /// `#[cfg(test)]` module can't depend on the cross-crate `bynk-testkit`
5408    /// (that crate depends on `bynk-emit` — a cyclic dev-dependency, the
5409    /// same class of issue slice 3 found for `bynk-ide`). Mirrors
5410    /// `bynk-testkit::compile_options_split` in-crate instead, directly
5411    /// against this crate's own `Roots`/`discover_project_files` — no second
5412    /// resolution to drift from the first. Keyed by the literal discovered
5413    /// path, not canonicalised, matching `bynk-testkit`'s own convention
5414    /// (canonicalising broke a project-consistency check the hard way in
5415    /// slice 3).
5416    /// Content-ownership track (#1086) slice 5: this crate's own tests can't
5417    /// depend on `bynk-testkit` (cyclic — `bynk-testkit` depends on
5418    /// `bynk-emit`), so its handful of sites that build a `Roots` and need
5419    /// real disk content for it mirror `bynk-testkit`'s own read, in-crate.
5420    fn read_disk_sources(roots: &Roots) -> HashMap<PathBuf, String> {
5421        discover_project_files(roots)
5422            .into_iter()
5423            .filter_map(|p| {
5424                let content = std::fs::read_to_string(&p).ok()?;
5425                Some((p, content))
5426            })
5427            .collect()
5428    }
5429
5430    fn compile_options_split_with_sources(
5431        project_root: PathBuf,
5432        paths: ProjectPaths,
5433    ) -> CompileOptions {
5434        let roots = Roots::Split {
5435            project_root: project_root.clone(),
5436            paths: paths.clone(),
5437        };
5438        let sources = read_disk_sources(&roots);
5439        CompileOptions::split(project_root, paths).sources(sources)
5440    }
5441
5442    // -- Finding #55/#65: memoized first-party parse must not leak gating ----
5443
5444    /// The first-party parse cache (`firstparty_parsed`) is keyed per-source,
5445    /// not per-project — `phase_parse`'s `consumes`/`uses` gating still runs
5446    /// fresh for every project. Two in-memory projects that gate in
5447    /// *different* first-party units, compiled back-to-back in the same
5448    /// process (so both share the same cache), must each see exactly their
5449    /// own gated-in set: `bynk.map` (which itself `uses bynk.list`) for the
5450    /// first, `bynk.string` alone for the second — never the other's.
5451    #[test]
5452    fn firstparty_cache_does_not_leak_gating_across_projects() {
5453        let out = compile_in_memory(
5454            "commons app.only_map\n\nuses bynk.map\n\nfn f() -> Int { 1 }\n",
5455            BuildTarget::Bundle,
5456            Default::default(),
5457        )
5458        .unwrap_or_else(|_| panic!("`uses bynk.map` should compile"));
5459        let paths: Vec<String> = out
5460            .artefacts
5461            .docs
5462            .keys()
5463            .map(|p| p.to_string_lossy().replace('\\', "/"))
5464            .collect();
5465        assert!(paths.iter().any(|p| p == "bynk/map.ts"), "{paths:?}");
5466        assert!(
5467            paths.iter().any(|p| p == "bynk/list.ts"),
5468            "bynk.map itself uses bynk.list, so list must be injected too: {paths:?}"
5469        );
5470        assert!(
5471            !paths.iter().any(|p| p.starts_with("bynk/string")),
5472            "a project that never uses bynk.string must not gain it: {paths:?}"
5473        );
5474
5475        let out2 = compile_in_memory(
5476            "commons app.only_string\n\nuses bynk.string\n\nfn f() -> String { \"x\" }\n",
5477            BuildTarget::Bundle,
5478            Default::default(),
5479        )
5480        .unwrap_or_else(|_| panic!("`uses bynk.string` should compile"));
5481        let paths2: Vec<String> = out2
5482            .artefacts
5483            .docs
5484            .keys()
5485            .map(|p| p.to_string_lossy().replace('\\', "/"))
5486            .collect();
5487        assert!(paths2.iter().any(|p| p == "bynk/string.ts"), "{paths2:?}");
5488        assert!(
5489            !paths2.iter().any(|p| p.starts_with("bynk/map")),
5490            "the shared first-party parse cache must not leak the first \
5491             project's gating into this one: {paths2:?}"
5492        );
5493    }
5494
5495    // -- Finding #64: `check_project` must not bail past an earlier error --
5496
5497    /// `compile_project`'s `Mode::Build` bails at the first structural error
5498    /// (here, `exports capability` naming an undeclared capability) and never
5499    /// reaches the per-unit checking pass — so a completely separate file's
5500    /// test-body type error is silently dropped. `check_project`'s
5501    /// `Mode::Analyse` must report both.
5502    #[test]
5503    fn check_project_reports_a_test_body_error_past_an_earlier_structural_error() {
5504        let root = scratch_project(
5505            "check_past_error",
5506            &[
5507                ("bynk.toml", "[project]\nname = \"c\"\n"),
5508                (
5509                    "src/greet.bynk",
5510                    "context greet {\n  exports capability { Bogus }\n}\n",
5511                ),
5512                (
5513                    "src/math.bynk",
5514                    "commons math {\n  fn double(n: Int) -> Int { n * 2 }\n}\n",
5515                ),
5516                (
5517                    "tests/math_test.bynk",
5518                    "suite math\n\ncase \"broken\" {\n  let x: Int = \"not an int\"\n  expect x == 1\n}\n",
5519                ),
5520            ],
5521        );
5522        let options = compile_options_split_with_sources(
5523            root.to_path_buf(),
5524            try_read_project_paths(&root).expect("well-formed fixture manifest"),
5525        );
5526
5527        let check = check_project(&options);
5528        assert!(check.has_errors());
5529        let categories: Vec<&str> = check.errors.iter().map(|ae| ae.error.category).collect();
5530        assert!(
5531            categories.contains(&"bynk.exports.undeclared_capability"),
5532            "{categories:?}"
5533        );
5534        assert!(
5535            categories.contains(&"bynk.types.let_annotation_mismatch"),
5536            "check_project must still report the test body's own type error \
5537             past the earlier structural error: {categories:?}"
5538        );
5539
5540        // The contrast: `compile_project`'s bail-fast `Mode::Build` is the
5541        // defect `check_project` exists to route `bynk check` around.
5542        let failure = match compile_project(&options) {
5543            Err(f) => f,
5544            Ok(_) => panic!("the structural error must still fail a real build"),
5545        };
5546        let failure_categories: Vec<&str> =
5547            failure.errors.iter().map(|ae| ae.error.category).collect();
5548        assert!(
5549            !failure_categories.contains(&"bynk.types.let_annotation_mismatch"),
5550            "compile_project must still bail before the test-body check runs \
5551             (documents why check_project is a separate entry point): {failure_categories:?}"
5552        );
5553    }
5554
5555    // -- Slice 0: file identity is not the unit-validation path ---------------
5556
5557    /// The defect, reproduced hermetically: two `include` roots each holding a
5558    /// file of the same name. Before slice 0 this yielded
5559    /// `["thing.bynk", "thing.bynk"]` — `parse_tree` stripped each tree's own
5560    /// root, so the two were indistinguishable and any consumer mapping by that
5561    /// key dropped one.
5562    ///
5563    /// This is the measurement from the track doc's §3.1, inverted into an
5564    /// assertion. It deliberately does **not** read `../examples/todo`:
5565    /// `bynk-emit` is published without an `exclude` list, so a test reaching
5566    /// outside the crate would fail a standalone `cargo test` on the released
5567    /// tarball. That `examples/todo` itself resolves is #647's regression
5568    /// fixture, where the LSP can actually observe it.
5569    #[test]
5570    fn split_roots_give_each_file_a_distinct_identity() {
5571        let root = scratch_project(
5572            "identity",
5573            &[
5574                ("bynk.toml", "[project]\nname = \"identity\"\n"),
5575                ("src/thing.bynk", "context thing\n"),
5576                ("tests/thing.bynk", "suite thing\n"),
5577            ],
5578        );
5579        let roots = Roots::Split {
5580            project_root: root.to_path_buf(),
5581            paths: try_read_project_paths(&root).expect("well-formed fixture manifest"),
5582        };
5583        let trees = roots.trees();
5584        assert_eq!(
5585            trees,
5586            vec![
5587                (root.join("src"), PathBuf::from("src")),
5588                (root.join("tests"), PathBuf::from("tests")),
5589            ],
5590            "the fixture must actually be two-rooted"
5591        );
5592        let sources = read_disk_sources(&roots);
5593        let run = run_checks(
5594            &trees,
5595            BuildTarget::Bundle,
5596            Platform::default(),
5597            ImportExt::Js,
5598            Mode::Analyse,
5599            &sources,
5600            &roots.excludes(),
5601            None,
5602            false,
5603            true,
5604            &SchemaLock::Off,
5605            None,
5606            roots.project_root(),
5607            None,
5608            None,
5609            false,
5610            &Arc::new(Types::new()),
5611        );
5612        let snapshots = match run {
5613            RunChecks::Bailed { snapshots, .. } => snapshots,
5614            RunChecks::Checked { snapshots, .. } => snapshots,
5615        };
5616        let mut keys: Vec<String> = snapshots
5617            .iter()
5618            .map(|(p, _)| p.to_string_lossy().replace('\\', "/"))
5619            .collect();
5620        keys.sort();
5621        assert_eq!(
5622            keys,
5623            vec!["src/thing.bynk", "tests/thing.bynk"],
5624            "a file's identity must be project-relative and unique across include roots"
5625        );
5626    }
5627
5628    /// Regression (code review of #1114): an adapter declared outside the
5629    /// first `include` tree used to have its `binding` module resolved
5630    /// against `trees[0]` unconditionally (`phase_group`'s old `src_root:
5631    /// &Path` parameter) — a project with `include = ["src", "adapters",
5632    /// "tests"]` and an adapter under `adapters/` would look for its binding
5633    /// under `src/`, fail to find it, and report `bynk.adapter.no_binding`
5634    /// even though the binding file exists right beside the adapter.
5635    #[test]
5636    fn adapter_binding_resolves_against_its_own_include_tree() {
5637        let root = scratch_project(
5638            "adapter_binding_tree",
5639            &[
5640                (
5641                    "bynk.toml",
5642                    "[project]\nname = \"a\"\n\n[paths]\ninclude = [\"src\", \"adapters\", \"tests\"]\n",
5643                ),
5644                (
5645                    "src/math.bynk",
5646                    "commons math {\n  fn double(n: Int) -> Int { n * 2 }\n}\n",
5647                ),
5648                (
5649                    "adapters/payments.bynk",
5650                    "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",
5651                ),
5652                (
5653                    "adapters/payments.binding.ts",
5654                    "import type { Pay } from \"./payments.js\";\n\nexport class RealPay implements Pay {\n  async charge(amount: number): globalThis.Promise<string> {\n    return \"ok\";\n  }\n}\n",
5655                ),
5656            ],
5657        );
5658        let options = compile_options_split_with_sources(
5659            root.to_path_buf(),
5660            try_read_project_paths(&root).expect("well-formed fixture manifest"),
5661        );
5662        let out = compile_project(&options).unwrap_or_else(|f| {
5663            panic!(
5664                "adapter with a binding in a non-first include tree must compile: {}",
5665                render(&f.errors)
5666            )
5667        });
5668        let names: Vec<String> = out
5669            .artefacts
5670            .docs
5671            .keys()
5672            .map(|p| p.to_string_lossy().replace('\\', "/"))
5673            .collect();
5674        assert!(
5675            names.contains(&"payments.binding.ts".to_string()),
5676            "expected the binding to be copied into the output among {names:?}"
5677        );
5678    }
5679
5680    /// Regression (code review of the #1114 fix itself): `tree_root_for`
5681    /// compared `pf.abs_path()` (always absolute, via `std::path::absolute`)
5682    /// against `trees`' roots as-is — for the ordinary CLI shape (a relative
5683    /// project root, e.g. `bynkc build .`), every tree root stays relative,
5684    /// so `starts_with` never matched and this silently fell back to
5685    /// `trees[0]` for every file, reproducing the exact bug the fix above
5686    /// exists to close. `scratch_project`-based tests never caught this
5687    /// because their project root is always an absolute temp path.
5688    #[test]
5689    fn tree_root_for_matches_against_a_relative_tree_root() {
5690        let trees = vec![
5691            (PathBuf::from("src"), PathBuf::from("src")),
5692            (PathBuf::from("adapters"), PathBuf::from("adapters")),
5693        ];
5694        let root = Path::new("adapters");
5695        // Relative, exactly as `discover_bynk_files`/`phase_parse` would pass
5696        // it when `Roots::Split.project_root` is itself relative.
5697        let rel_path = root.join("payments.bynk");
5698        let (parsed, _warnings) = parse_sources(
5699            root,
5700            Path::new("adapters"),
5701            &rel_path,
5702            "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(),
5703        )
5704        .expect("trivial adapter source must parse");
5705        assert_eq!(
5706            project_model::tree_root_for(&trees, &parsed[0]),
5707            Path::new("adapters"),
5708            "must resolve to the adapter's own (relative) tree root, not trees[0] (\"src\")"
5709        );
5710    }
5711
5712    /// Regression (code review of #1114): the emitted test module's
5713    /// discovered-case location used to key off `trees.get(1)`'s prefix
5714    /// unconditionally (`tests_prefix` in `process_tests`/
5715    /// `emit_test_module`) — a project with `include = ["src", "examples",
5716    /// "tests"]` would prefix every discovered case's location with
5717    /// `examples/` (the second tree) even though the suite actually lives
5718    /// under `tests/` (the third).
5719    #[test]
5720    fn discovered_case_location_uses_the_suite_files_own_tree() {
5721        let root = scratch_project(
5722            "test_tree_prefix",
5723            &[
5724                (
5725                    "bynk.toml",
5726                    "[project]\nname = \"t\"\n\n[paths]\ninclude = [\"src\", \"examples\", \"tests\"]\n",
5727                ),
5728                (
5729                    "src/math.bynk",
5730                    "commons math {\n  fn double(n: Int) -> Int { n * 2 }\n}\n",
5731                ),
5732                (
5733                    "tests/math_test.bynk",
5734                    "suite math\n\ncase \"doubles\" {\n  expect double(2) == 4\n}\n",
5735                ),
5736            ],
5737        );
5738        let options = compile_options_split_with_sources(
5739            root.to_path_buf(),
5740            try_read_project_paths(&root).expect("well-formed fixture manifest"),
5741        );
5742        let out = compile_project(&options).unwrap_or_else(|f| {
5743            panic!(
5744                "a suite in the third include tree must compile: {}",
5745                render(&f.errors)
5746            )
5747        });
5748        let locations: Vec<String> = out
5749            .discovered
5750            .iter()
5751            .flat_map(|s| &s.cases)
5752            .filter_map(|c| c.location.as_ref())
5753            .map(|l| l.path.clone())
5754            .collect();
5755        assert!(
5756            locations
5757                .iter()
5758                .all(|p| p.starts_with("tests/") && !p.starts_with("examples/")),
5759            "case locations must key off the suite file's own tree (`tests/`), not the \
5760             second `include` tree (`examples/`): {locations:?}"
5761        );
5762    }
5763
5764    /// #57 (testing track): a two-file, cross-referencing project compiled
5765    /// entirely through the public `compile_project` API with no on-disk
5766    /// tree at all — `CompileOptions::sources` replaces what
5767    /// `scratch_project` below has to fake with real temp-directory I/O.
5768    /// Before this seam, exercising `uses` across two units from inside
5769    /// `bynk-emit`'s own tests meant either a `scratch_project` (real files,
5770    /// cleaned up on drop) or `bynkc`'s on-disk fixtures one crate up.
5771    #[test]
5772    fn compile_project_with_in_memory_sources_resolves_a_cross_unit_uses() {
5773        let mut sources = HashMap::new();
5774        sources.insert(
5775            PathBuf::from("shapes.bynk"),
5776            "commons shapes\n\ntype Circle = { radius: Int }\n".to_string(),
5777        );
5778        sources.insert(
5779            PathBuf::from("app.bynk"),
5780            "commons app\n\nuses shapes\n\nfn area(c: Circle) -> Int {\n  c.radius * c.radius\n}\n"
5781                .to_string(),
5782        );
5783        let options = CompileOptions::single(".").sources(sources);
5784        let out = compile_project(&options).unwrap_or_else(|f| {
5785            panic!(
5786                "in-memory sources project should compile: {:?}",
5787                ProjectFailure::flatten(f)
5788            )
5789        });
5790        let names: Vec<String> = out
5791            .artefacts
5792            .docs
5793            .keys()
5794            .map(|p| p.to_string_lossy().replace('\\', "/"))
5795            .collect();
5796        assert!(
5797            names.contains(&"shapes.ts".to_string()),
5798            "expected shapes.ts among {names:?}"
5799        );
5800        assert!(
5801            names.contains(&"app.ts".to_string()),
5802            "expected app.ts among {names:?}"
5803        );
5804        let app_ts = out.artefacts.docs.get(Path::new("app.ts")).unwrap().text();
5805        assert!(
5806            app_ts.contains("radius"),
5807            "app.ts should reference the cross-unit Circle field:\n{app_ts}"
5808        );
5809    }
5810
5811    /// A throwaway on-disk project, removed on drop — including when the test
5812    /// panics, which a trailing `remove_dir_all` would skip.
5813    struct Scratch(PathBuf);
5814    impl std::ops::Deref for Scratch {
5815        type Target = Path;
5816        fn deref(&self) -> &Path {
5817            &self.0
5818        }
5819    }
5820    impl Drop for Scratch {
5821        fn drop(&mut self) {
5822            let _ = fs::remove_dir_all(&self.0);
5823        }
5824    }
5825
5826    /// Build a throwaway on-disk project. The e2e fixture suite cannot express
5827    /// these cases: `expected_error.txt` asserts *category strings only*, never
5828    /// a path, so no fixture there can pin attribution — which is precisely why
5829    /// the identity collision survived to slice 0.
5830    fn scratch_project(tag: &str, files: &[(&str, &str)]) -> Scratch {
5831        let dir = std::env::temp_dir().join(format!(
5832            "bynk_slice0_{tag}_{}_{:?}",
5833            std::process::id(),
5834            std::thread::current().id()
5835        ));
5836        let _ = fs::remove_dir_all(&dir);
5837        for (rel, body) in files {
5838            let p = dir.join(rel);
5839            fs::create_dir_all(p.parent().unwrap()).unwrap();
5840            fs::write(&p, body).unwrap();
5841        }
5842        Scratch(dir)
5843    }
5844
5845    /// `AttributedError` is public API without a `Debug` impl; slice 0 is not
5846    /// the increment to add one, so tests render it themselves.
5847    fn render<'a>(errors: impl IntoIterator<Item = &'a AttributedError>) -> String {
5848        errors
5849            .into_iter()
5850            .map(|e| {
5851                format!(
5852                    "{} @ {}",
5853                    e.error.category,
5854                    e.source_path
5855                        .as_ref()
5856                        .map(|p| p.to_string_lossy().replace('\\', "/"))
5857                        .unwrap_or_else(|| "<unattributed>".into())
5858                )
5859            })
5860            .collect::<Vec<_>>()
5861            .join(", ")
5862    }
5863
5864    fn analyse_split(root: &Path) -> Vec<AttributedError> {
5865        let roots = Roots::Split {
5866            project_root: root.to_path_buf(),
5867            paths: try_read_project_paths(root).expect("well-formed fixture manifest"),
5868        };
5869        let trees = roots.trees();
5870        let sources = read_disk_sources(&roots);
5871        let run = run_checks(
5872            &trees,
5873            BuildTarget::Bundle,
5874            Platform::default(),
5875            ImportExt::Js,
5876            Mode::Analyse,
5877            &sources,
5878            &roots.excludes(),
5879            None,
5880            false,
5881            true,
5882            &SchemaLock::Off,
5883            None,
5884            roots.project_root(),
5885            None,
5886            None,
5887            false,
5888            &Arc::new(Types::new()),
5889        );
5890        match run {
5891            RunChecks::Bailed { errors, .. } => errors.into_all(),
5892            RunChecks::Checked { errors, .. } => errors.into_all(),
5893        }
5894    }
5895
5896    /// The defect's user-visible half: a diagnostic in a secondary-root file
5897    /// must be attributed to *that* file. Before slice 0 both roots' files were
5898    /// named `thing.bynk`, so a consumer keying by the attributed path (the LSP
5899    /// does) folded the two together and one file's diagnostics vanished.
5900    #[test]
5901    fn a_secondary_root_diagnostic_is_attributed_to_the_secondary_root_file() {
5902        let root = scratch_project(
5903            "attr",
5904            &[
5905                ("bynk.toml", "[project]\nname = \"attr\"\n"),
5906                ("src/thing.bynk", "context thing\n"),
5907                // Same basename as the src file, different root — the collision.
5908                //
5909                // A *parse* error, deliberately: `parse_tree` attributes it as
5910                // the file is read, which is the path slice 0 changed. (A
5911                // checker-level error would not do: test bodies are checked by
5912                // `process_tests` during emit, not in `Mode::Analyse` — which is
5913                // also why `bynkc check` is silent on a broken `case`.)
5914                ("tests/thing.bynk", "suite thing\n\ncase {{{ \n"),
5915            ],
5916        );
5917        let errors = analyse_split(&root);
5918        let paths: Vec<String> = errors
5919            .iter()
5920            .filter_map(|e| e.source_path.as_ref())
5921            .map(|p| p.to_string_lossy().replace('\\', "/"))
5922            .collect();
5923        assert!(
5924            !paths.is_empty(),
5925            "the fixture must produce at least one attributed diagnostic; got [{}]",
5926            render(&errors),
5927        );
5928        assert!(
5929            paths.iter().all(|p| p == "tests/thing.bynk"),
5930            "a tests-root diagnostic must be attributed to `tests/thing.bynk`, \
5931             never the bare `thing.bynk` it shares with `src/` — got {paths:?}",
5932        );
5933    }
5934
5935    /// The layout that ruled out the cheaper repair. Prefixing only the
5936    /// secondary tree would have worked for a `tests/` tree of suites — but
5937    /// ADR 0147 made test-ness *structural*, so `include[1]` may hold an
5938    /// ordinary unit. `check_path_name_alignment` reads `source_path`
5939    /// (tree-relative), so that unit must still validate: `spec/other.bynk`
5940    /// declaring `context other` is aligned, and prefixing its
5941    /// unit-validation path would have broken it.
5942    #[test]
5943    fn a_non_test_unit_in_the_secondary_root_still_validates() {
5944        let root = scratch_project(
5945            "nontest",
5946            &[
5947                (
5948                    "bynk.toml",
5949                    "[project]\nname = \"nontest\"\n\n[paths]\ninclude = [\"src\", \"spec\"]\n",
5950                ),
5951                ("src/thing.bynk", "context thing\n"),
5952                ("spec/other.bynk", "context other\n"),
5953            ],
5954        );
5955        let errors = analyse_split(&root);
5956        let alignment: Vec<&AttributedError> = errors
5957            .iter()
5958            .filter(|e| e.error.category == "bynk.project.inconsistent_commons_name")
5959            .collect();
5960        assert!(
5961            alignment.is_empty(),
5962            "a non-test unit in include[1] must still pass path/name alignment — \
5963             its unit-validation path stays tree-relative; got [{}]",
5964            render(alignment.iter().copied()),
5965        );
5966    }
5967
5968    /// End-to-end over the flat layout `conventional()` actually produces, so
5969    /// the normalisation is pinned where it is reachable and not only on the
5970    /// accessor. No e2e fixture has this layout.
5971    #[test]
5972    fn a_flat_project_with_a_manifest_reports_unprefixed_paths() {
5973        let root = scratch_project(
5974            "flat",
5975            &[
5976                ("bynk.toml", "[project]\nname = \"flat\"\n"),
5977                // Parse error: `parse_tree` attributes it as the file is read.
5978                ("thing.bynk", "context thing\n\nfn {{{ \n"),
5979            ],
5980        );
5981        let paths = try_read_project_paths(&root).expect("well-formed fixture manifest");
5982        assert_eq!(
5983            paths.include,
5984            vec![PathBuf::from(".")],
5985            "the fixture must actually exercise the flat layout"
5986        );
5987        let errors = analyse_split(&root);
5988        let attributed: Vec<String> = errors
5989            .iter()
5990            .filter_map(|e| e.source_path.as_ref())
5991            .map(|p| p.to_string_lossy().replace('\\', "/"))
5992            .collect();
5993        assert!(
5994            !attributed.is_empty(),
5995            "the fixture must produce an attributed diagnostic; got [{}]",
5996            render(&errors),
5997        );
5998        assert!(
5999            attributed.iter().all(|p| p == "thing.bynk"),
6000            "a flat project's diagnostics must report `thing.bynk`, never \
6001             `./thing.bynk` — got {attributed:?}",
6002        );
6003    }
6004
6005    /// v0.29.4: assembly yields exactly one `UnitInfo` per group, every facet
6006    /// present, with `exports`/`aliases`/`flattened` defaulting to empty for a
6007    /// unit absent from those (genuinely optional) producer maps — reproducing
6008    /// the old `.unwrap_or(empty)` read semantics as a total field.
6009    #[test]
6010    fn assemble_unit_info_yields_one_record_per_group_with_all_facets() {
6011        let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
6012        groups.insert("a.commons".to_string(), vec![0, 1]);
6013        groups.insert("a.context".to_string(), vec![2]);
6014
6015        let mut kinds: BTreeMap<String, UnitKind> = BTreeMap::new();
6016        kinds.insert("a.commons".to_string(), UnitKind::Commons);
6017        kinds.insert("a.context".to_string(), UnitKind::Context);
6018
6019        let mut unit_tables: HashMap<String, UnitTable> = HashMap::new();
6020        unit_tables.insert("a.commons".to_string(), UnitTable::default());
6021        unit_tables.insert("a.context".to_string(), UnitTable::default());
6022
6023        let mut unit_uses: HashMap<String, Vec<String>> = HashMap::new();
6024        unit_uses.insert("a.context".to_string(), vec!["a.commons".to_string()]);
6025
6026        let mut unit_consumes: HashMap<String, Vec<String>> = HashMap::new();
6027        unit_consumes.insert("a.context".to_string(), vec![]);
6028
6029        // The genuinely-optional maps deliberately omit `a.commons` so the test
6030        // pins the empty-default behaviour.
6031        let mut unit_flattened: HashMap<String, HashMap<String, String>> = HashMap::new();
6032        unit_flattened.insert("a.context".to_string(), HashMap::new());
6033        let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = HashMap::new();
6034        let mut exports_visibility: HashMap<String, HashMap<String, Visibility>> = HashMap::new();
6035        exports_visibility.insert("a.context".to_string(), HashMap::new());
6036
6037        let mut unit_file_index: HashMap<String, FileDeclIndex> = HashMap::new();
6038        unit_file_index.insert(
6039            "a.commons".to_string(),
6040            FileDeclIndex {
6041                types: HashMap::new(),
6042                fns: HashMap::new(),
6043                methods: HashMap::new(),
6044                agents: HashMap::new(),
6045                capabilities: HashMap::new(),
6046            },
6047        );
6048        // `a.context` is absent from the file index → its `file_index` defaults.
6049
6050        let info = project_model::assemble_unit_info(
6051            &groups,
6052            &kinds,
6053            &unit_tables,
6054            &unit_uses,
6055            &unit_consumes,
6056            &unit_flattened,
6057            &unit_consumes_aliases,
6058            &exports_visibility,
6059            &unit_file_index,
6060        );
6061
6062        // One record per group, no more.
6063        assert_eq!(info.len(), 2);
6064        assert!(info.contains_key("a.commons"));
6065        assert!(info.contains_key("a.context"));
6066
6067        // `files` mirrors the `groups` indices.
6068        assert_eq!(info["a.commons"].files, vec![0, 1]);
6069        assert_eq!(info["a.context"].files, vec![2]);
6070
6071        // Non-optional facets are filled from their producer maps.
6072        assert_eq!(info["a.commons"].kind, UnitKind::Commons);
6073        assert_eq!(info["a.context"].kind, UnitKind::Context);
6074        assert_eq!(info["a.context"].uses, vec!["a.commons".to_string()]);
6075
6076        // Optional facets default to empty for the unit with no entry.
6077        assert!(info["a.commons"].exports.is_empty());
6078        assert!(info["a.commons"].aliases.is_empty());
6079        assert!(info["a.commons"].flattened.is_empty());
6080        // And the absent `file_index` is an empty index, not a panic.
6081        assert!(info["a.context"].file_index.types.is_empty());
6082        assert!(info["a.context"].file_index.fns.is_empty());
6083        assert!(info["a.context"].file_index.methods.is_empty());
6084    }
6085
6086    // -- #397: analyse_in_memory_with_types exposes expr_types (ADR 0094) -----
6087
6088    #[test]
6089    fn analyse_in_memory_with_types_reports_expr_types_for_clean_source() {
6090        let src = "commons app.demo\n\nfn good() -> Int {\n  42\n}\n";
6091        let out = analyse_in_memory_with_types(src, BuildTarget::Bundle, Platform::default());
6092        assert!(
6093            out.errors.is_empty(),
6094            "clean source should have no errors: {:?}",
6095            out.errors
6096                .iter()
6097                .map(|e| &e.error.message)
6098                .collect::<Vec<_>>()
6099        );
6100        let offset = src.find("42").expect("source mentions 42");
6101        let ty = bynk_check::expr_types::type_at_offset(&out.expr_types, offset, &out.ty_intern);
6102        assert_eq!(
6103            ty.map(|t| t.display(&out.ty_intern)),
6104            Some("Int".to_string())
6105        );
6106    }
6107
6108    #[test]
6109    fn analyse_in_memory_with_types_is_partial_under_a_sibling_error() {
6110        // ADR 0094: a function that types cleanly still contributes its
6111        // `expr_types` even though a *different* function in the same file
6112        // has an error — `check_record`'s pre-ADR-0094 all-or-nothing gate
6113        // applied per-file, not per-function, and this is the change that
6114        // relaxed it. Hover (#397) depends on this: it must not go blank
6115        // over a well-typed expression just because some other function in
6116        // the buffer is mid-edit and broken.
6117        let src = "commons app.demo\n\n\
6118            fn good() -> Int {\n  42\n}\n\n\
6119            fn bad() -> Int {\n  \"oops\"\n}\n";
6120        let out = analyse_in_memory_with_types(src, BuildTarget::Bundle, Platform::default());
6121        assert!(
6122            !out.errors.is_empty(),
6123            "the broken function must still be reported"
6124        );
6125        let offset = src.find("42").expect("source mentions 42");
6126        let ty = bynk_check::expr_types::type_at_offset(&out.expr_types, offset, &out.ty_intern);
6127        assert_eq!(
6128            ty.map(|t| t.display(&out.ty_intern)),
6129            Some("Int".to_string()),
6130            "the clean function's types must survive the sibling error"
6131        );
6132    }
6133
6134    #[test]
6135    fn analyse_in_memory_still_returns_exactly_the_typed_variants_errors() {
6136        // Pins the refactor: `analyse_in_memory` must keep delegating to
6137        // `analyse_in_memory_with_types` rather than drift into a second,
6138        // independently-maintained `run_checks` call.
6139        let src = "commons app.demo\n\nfn bad() -> Int {\n  \"oops\"\n}\n";
6140        let errs = analyse_in_memory(src, BuildTarget::Bundle, Platform::default());
6141        let typed = analyse_in_memory_with_types(src, BuildTarget::Bundle, Platform::default());
6142        assert_eq!(errs.len(), typed.errors.len());
6143        assert!(!errs.is_empty());
6144    }
6145
6146    // -- T3.3b: `expr_types` is total (R4.3, R2.5, R4.9) -----------------
6147
6148    #[test]
6149    fn a_diagnosed_resolution_failure_records_ty_error_instead_of_nothing() {
6150        // An empty list literal with no expected element type to infer from
6151        // (`bynk.types.uninferable_element_type`, `checker.rs`'s `type_of`
6152        // `ExprKind::ListLit` arm) is a genuine, diagnosed `type_of` failure
6153        // reachable from a plain `fn` — no resolver/handler-body plumbing
6154        // needed to reproduce it.
6155        let src = "commons app.demo\n\nfn bad() -> Int {\n  []\n}\n";
6156        let out = analyse_in_memory_with_types(src, BuildTarget::Bundle, Platform::default());
6157        assert!(
6158            out.errors
6159                .iter()
6160                .any(|e| e.error.category == "bynk.types.uninferable_element_type"),
6161            "expected the uninferable-element-type diagnostic: {:?}",
6162            out.errors
6163                .iter()
6164                .map(|e| &e.error.message)
6165                .collect::<Vec<_>>()
6166        );
6167        let start = src.find("[]").expect("source mentions []");
6168        let recorded = out
6169            .expr_types
6170            .iter()
6171            .find(|(span, _)| span.start == start && span.end == start + 2)
6172            .map(|(_, t)| t.display(&out.ty_intern));
6173        assert_eq!(
6174            recorded.as_deref(),
6175            Some("<type error>"),
6176            "T3.3b: a diagnosed type_of failure must record Ty::Error, not leave the span \
6177             unrecorded"
6178        );
6179        // An editor query reads that recorded `Ty::Error` as "no type".
6180        assert_eq!(
6181            bynk_check::expr_types::type_at_offset(&out.expr_types, start, &out.ty_intern),
6182            None
6183        );
6184    }
6185}