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(®));
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}