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