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