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