Skip to main content

bynk_check/
project_model.rs

1//! Project-wide orchestration: discovery → parse → group → resolve, shared
2//! between `bynk-emit`'s `run_checks` (both `Mode::Build` and `Mode::Analyse`)
3//! and this crate's own [`crate::analysis::analyse_project`].
4//!
5//! P4.1 (#1115), second scope finding on the tracking issue: this pipeline —
6//! `phase_discovery` through `assemble_unit_info`, plus the per-unit symbol
7//! composition (`compose_unit_symbols`/`merge_consumed_exports`/
8//! `collect_unit_methods`) — used to live only in `bynk-emit/src/project.rs`,
9//! inline in `run_checks`. A literal no-indirection `bynk-check`-side analysis
10//! entry point needs the identical sequence, so rather than write a second,
11//! independently-maintained copy (the mistake this whole design track's
12//! `extract, don't duplicate` principle exists to prevent — see
13//! `lower_field_default_wire`, `build_capability_op_info` for the same move
14//! made earlier in this track), it moved here. `bynk-emit`'s `run_checks`
15//! becomes a caller of these functions instead of owning the logic, the same
16//! way P4.0 turned `project.rs` into a caller of `bynk-project`.
17//!
18//! What stayed in `bynk-emit` (not shared, because only the `Mode::Build` path
19//! needs it, or because it's genuinely emission-shaped): the `Mode::Build`
20//! bail gate and everything from emission onward (`EmitUnitCtx`, `emit_unit`,
21//! `collect_history_target_agents`). The whole-project `messages`/locale-
22//! ambiguity/event-subscription checks (P5.0/P5.1), the function-type-
23//! boundary check (P5.2, [`phase_function_type_boundaries`]), and
24//! schema-registry reconciliation/platform-lock enforcement (P5.3,
25//! [`crate::schema_registry::reconcile`]/[`phase_platform_lock`]) have since
26//! moved here too — the P5.2 move closed `phase_group`'s optional
27//! boundary-check hook, which used to be the only way `run_checks` and the
28//! new entry point could reach it without duplicating the diagnostic-ordering
29//! logic (see `analysis.rs` for the residual-gap accounting that remains).
30
31use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
32use std::path::{Path, PathBuf};
33use std::sync::{Arc, OnceLock};
34
35use crate::checker::{self, Ty, TyId, Types};
36use crate::context_checks::{
37    build_capability_op_info, reject_fn_types, ts_type_ref_display, type_ref_is_held,
38    type_ref_to_display, validate_store_field_value_types,
39};
40use crate::firstparty::{self, Platform};
41use crate::icu;
42use crate::index::{RefSink, SymbolKind};
43use crate::resolver::MethodTable as ResolverMethodTable;
44use crate::symbols::{
45    ConsumedType, ContextMessageBundle, FileDeclIndex, UnitTable, build_file_decl_index,
46    build_unit_table, consumes_span_of, detect_context_message_bundle, parsed_alias_span,
47    uses_span_of,
48};
49use bynk_project::{
50    AttributedError, ParsedFile, UnitKind, check_directory_kind_consistency,
51    check_directory_name_consistency, check_file_directory_conflicts, check_group_kind_consistency,
52    check_path_name_alignment, detect_consumes_cycles, discover_bynk_files, is_unpinned_range,
53    normalize_rel, parse_sources, read_adapter_binding, read_source,
54};
55use bynk_syntax::ast::*;
56use bynk_syntax::error::CompileError;
57use bynk_syntax::lexer;
58use bynk_syntax::parser;
59use bynk_syntax::span::Span;
60
61/// Collection-point error sink (ADR 0052). Helpers keep their plain
62/// `&mut Vec<CompileError>` signatures; call sites attribute via
63/// `extend_for` with the file in scope at that point.
64///
65/// P4.1 (#1115): relocated from `bynk-emit/src/project/diagnostics.rs`
66/// alongside the `phase_*` functions above, which all take `&mut ErrorSink` —
67/// the same "shared logic pulls its own types down with it" pattern already
68/// applied to `UnitTable`/`ConsumedType` in the `symbols.rs` move. `Mode` and
69/// `ProjectFailure` (the other two `diagnostics.rs` pipeline-driving types)
70/// stayed in `bynk-emit`, unaffected — neither is a dependency of anything
71/// this module needs.
72pub struct ErrorSink {
73    entries: Vec<AttributedError>,
74    /// v0.89 (ADR 0117): non-failing warnings, classified on push by
75    /// `Severity::for_error`. Kept apart so `is_empty`/`len` — the build-failure
76    /// gates — stay errors-only, while every warning source (commons-fn checks,
77    /// service/agent handler validation, parser) is captured uniformly.
78    warnings: Vec<AttributedError>,
79}
80
81impl Default for ErrorSink {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl ErrorSink {
88    pub fn new() -> Self {
89        Self {
90            entries: Vec::new(),
91            warnings: Vec::new(),
92        }
93    }
94    pub fn push_for(&mut self, file: Option<&Path>, error: CompileError) {
95        let attributed = AttributedError {
96            source_path: file.map(Path::to_path_buf),
97            error,
98        };
99        match bynk_syntax::Severity::for_error(&attributed.error) {
100            bynk_syntax::Severity::Warning => self.warnings.push(attributed),
101            bynk_syntax::Severity::Error => self.entries.push(attributed),
102        }
103    }
104    pub fn extend_for(
105        &mut self,
106        file: Option<&Path>,
107        errs: impl IntoIterator<Item = CompileError>,
108    ) {
109        for e in errs {
110            self.push_for(file, e);
111        }
112    }
113    /// True when no **error-severity** diagnostic has been collected — the
114    /// build-failure gate. Warnings do not count (ADR 0117).
115    pub fn is_empty(&self) -> bool {
116        self.entries.is_empty()
117    }
118    /// Consume the sink, yielding the non-failing **warnings** (ADR 0117).
119    pub fn into_warnings(self) -> Vec<AttributedError> {
120        self.warnings
121    }
122    /// Consume the sink, yielding errors then warnings — the full diagnostic
123    /// list the LSP and a failed build render together.
124    pub fn into_all(self) -> Vec<AttributedError> {
125        let mut all = self.entries;
126        all.extend(self.warnings);
127        all
128    }
129    /// The count of **error-severity** diagnostics.
130    pub fn len(&self) -> usize {
131        self.entries.len()
132    }
133}
134
135/// v0.17: a resolved adapter binding — the user-authored `.binding.ts` module
136/// that supplies an adapter's external provider symbols. Copied verbatim into
137/// the output beside the adapter's emitted interface module so that `tsc`
138/// checks the `implements` contract and compose can import the symbols.
139pub struct AdapterBinding {
140    /// Output path, relative to the output root (e.g. `tokens.binding.ts`).
141    pub output_path: PathBuf,
142    /// Verbatim TypeScript content read from the source tree.
143    pub content: String,
144}
145
146/// The build target. Determines how cross-context calls and per-context
147/// modules are emitted (v0.8). Bundle mode is the default — all contexts
148/// emit into one TypeScript bundle and cross-context calls are direct
149/// function invocations. Workers mode produces per-context Cloudflare
150/// Worker bundles that communicate via Service Bindings.
151#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
152pub enum BuildTarget {
153    /// Existing behaviour: one TS bundle, direct function calls between
154    /// contexts.
155    #[default]
156    Bundle,
157    /// One Worker per context. Cross-context calls become Service Binding
158    /// invocations using a JSON wire format with refinement validation on
159    /// the receiving side.
160    Workers,
161}
162
163pub fn normalize_service_defaults(parsed: &mut [ParsedFile]) {
164    for pf in parsed.iter_mut() {
165        let items = match pf.unit_mut() {
166            SourceUnit::Commons(c) => &mut c.items,
167            SourceUnit::Context(c) => &mut c.items,
168            SourceUnit::Adapter(a) => &mut a.items,
169            SourceUnit::Suite(_) => continue,
170        };
171        for item in items.iter_mut() {
172            if let CommonsItem::Service(svc) = item {
173                inject_service_defaults(svc);
174            }
175        }
176    }
177}
178
179/// Inject a single service's `by`/`given` defaults into its handlers. A handler
180/// that names its own `by` (or `given`) overrides the default outright — the
181/// default fills only an *absent* clause, never merges. A service with no default
182/// is left untouched (byte-for-byte the pre-v0.155 behaviour).
183pub fn inject_service_defaults(svc: &mut ServiceDecl) {
184    let default_by = svc.default_by.clone();
185    let default_given = svc.default_given.clone();
186    if default_by.is_none() && default_given.is_empty() {
187        return;
188    }
189    for handler in svc.handlers.iter_mut() {
190        if handler.by_clause.is_none()
191            && let Some(def) = &default_by
192        {
193            handler.by_clause = Some(def.clone());
194        }
195        if handler.given.is_empty() && !default_given.is_empty() {
196            handler.given = default_given.clone();
197        }
198    }
199}
200
201/// Phase 1: discover the `.bynk` files under the source (and, in split mode,
202/// the tests) root by walking the filesystem. Pushes any discovery error into
203/// `errors` and signals a pipeline bail via `Err(())` (the caller terminates
204/// with `finish`); otherwise returns the discovered `(src_files, tests_files)`.
205///
206/// #1077/#1081 review: this is the on-disk half only — `no_sources`/
207/// `check_file_directory_conflicts` moved to [`check_discovered_files`], which
208/// `run_checks` calls on the result *either* this walk *or* a caller-supplied
209/// `discovered` list produces, so a `CompileOptions.sources`-driven compile
210/// (the CLI's own path as of #1081) still gets both checks — they are
211/// properties of "what files does this build have," not of having just
212/// walked the disk to find them.
213/// R3.9 (#1113): walks every `(root, prefix)` tree `Roots::trees` resolves
214/// to, not a hardcoded primary/secondary pair — `trees[0]` is the mandatory
215/// tree (a missing directory is a real error, via `discover_bynk_files`
216/// itself); every later tree is optional, same as the old secondary tree
217/// always was (a project may simply have no such subtree).
218#[allow(clippy::result_unit_err)]
219pub fn phase_discovery(
220    trees: &[(PathBuf, PathBuf)],
221    excludes: &[PathBuf],
222    errors: &mut ErrorSink,
223) -> Result<Vec<Vec<PathBuf>>, ()> {
224    let mut out = Vec::with_capacity(trees.len());
225    for (i, (root, _prefix)) in trees.iter().enumerate() {
226        match discover_bynk_files(root, excludes) {
227            Ok(f) => out.push(f),
228            // Every tree past the first is optional — a missing directory is
229            // not an error, same as the old secondary tree always was. Tried
230            // via `discover_bynk_files` itself (a `fs::read_dir`) rather than
231            // a `root.exists()` pre-check, which would cost a redundant
232            // `stat()` per optional tree for the same answer.
233            Err(e) if i > 0 && e.category == "bynk.project.no_root" => {
234                out.push(Vec::new());
235            }
236            Err(e) => {
237                errors.push_for(None, e);
238                return Err(());
239            }
240        }
241    }
242    Ok(out)
243}
244
245/// The checks every tree's file list must pass regardless of where it came
246/// from — a real disk walk ([`phase_discovery`]) or a caller-supplied
247/// `discovered`/`CompileOptions.sources` list (#1077/#1081 review). An empty
248/// project (`bynk.project.no_sources`) signals a bail via `Err(())`; a
249/// file/directory name conflict is a non-fatal diagnostic.
250#[allow(clippy::result_unit_err)]
251pub fn check_discovered_files(
252    trees: &[(PathBuf, PathBuf)],
253    file_lists: &[Vec<PathBuf>],
254    errors: &mut ErrorSink,
255) -> Result<(), ()> {
256    if file_lists.iter().all(|f| f.is_empty()) {
257        errors.push_for(
258            None,
259            CompileError::new(
260                "bynk.project.no_sources",
261                Span::default(),
262                format!(
263                    "no `.bynk` source files found under {}",
264                    trees[0].0.display()
265                ),
266            ),
267        );
268        return Err(());
269    }
270    for ((root, _prefix), files) in trees.iter().zip(file_lists.iter()) {
271        if let Err(e) = check_file_directory_conflicts(root, files) {
272            errors.extend_for(None, e);
273        }
274    }
275    Ok(())
276}
277
278/// A memoized parse of one first-party synthetic source, keyed by the
279/// call-site's own `cache` static — each of `phase_parse`'s 7 injection sites
280/// below passes a distinct one. Finding #55/#65: the source text is a fixed
281/// `include_str!` constant, so its parse is a pure function of that constant
282/// and only needs computing once per process, not once per compile/analyse
283/// round. The gating below (`consumes_bynk`, `uses_map`, etc.) is unaffected —
284/// it still runs fresh for every project from that project's own parsed
285/// `uses`/`consumes`; only the parse *result* being gated is cached.
286/// T3.4 (R2.4): each first-party synthetic unit reserves its own 1M-wide
287/// `ExprId` block, spaced far above anything a real project's own file count
288/// could ever reach — see [`firstparty_parsed`]'s doc comment for why a fixed
289/// reservation, not a threaded counter, is the right shape here.
290pub const FIRSTPARTY_ID_BLOCK: u32 = 1_000_000;
291pub const FIRSTPARTY_ID_BASE: u32 = 1_000_000_000;
292
293pub fn firstparty_parsed(
294    cache: &'static OnceLock<Result<ParsedFile, Vec<CompileError>>>,
295    identity_path: &'static str,
296    src: &'static str,
297    kind: UnitKind,
298    // T3.4 (R2.4): a fixed base, not a live project counter — `cache` is a
299    // `OnceLock`, parsed once per *process*, and reused as-is across every
300    // later compile in that process regardless of how many real files that
301    // *particular* compile happens to have. A threaded counter can't work
302    // here (this parse doesn't know, and must never depend on, which compile
303    // triggers it first); a fixed, permanently-reserved range that no real
304    // project could ever grow into does. Call sites space their bases
305    // `FIRSTPARTY_ID_BLOCK` apart so the (currently seven) first-party units
306    // can never collide with each other either, however many of them one
307    // project ends up injecting together.
308    id_base: u32,
309) -> Result<ParsedFile, Vec<CompileError>> {
310    cache
311        .get_or_init(|| {
312            lexer::tokenize(src)
313                .map_err(|e| vec![e])
314                .and_then(|toks| {
315                    parser::parse_unit_with_warnings_from(&toks, src, &mut { id_base })
316                        .map(|(unit, _warnings)| unit)
317                })
318                .map(|unit| {
319                    ParsedFile::synthetic(
320                        PathBuf::from(identity_path),
321                        PathBuf::from(identity_path),
322                        src.to_string(),
323                        unit,
324                        kind,
325                    )
326                })
327        })
328        .clone()
329}
330
331/// Phase 2: parse every discovered file into a `ParsedFile`, recording each
332/// file's source text into `snapshots` and any parse errors into `errors`.
333/// Then inject the first-party synthetic units (the `bynk`/`bynk.cloudflare`
334/// adapters and the `bynk.{list,map,string}` commons) that the project
335/// consumes/uses. Returns the parsed units plus whether the `bynk` and
336/// `bynk.cloudflare` adapters were injected; signals a pipeline bail via
337/// `Err(())` when parsing produced errors and yielded no units at all.
338#[allow(clippy::too_many_arguments)]
339#[allow(clippy::result_unit_err)]
340pub fn phase_parse(
341    // R3.9 (#1113): one `(root, prefix)` pair per `Roots::trees` entry, not a
342    // hardcoded primary/secondary pair — every `include` tree is walked.
343    trees: &[(PathBuf, PathBuf)],
344    file_lists: &[Vec<PathBuf>],
345    overlay: &HashMap<PathBuf, String>,
346    errors: &mut ErrorSink,
347    snapshots: &mut Vec<(PathBuf, String)>,
348) -> Result<(Vec<ParsedFile>, bool, bool), ()> {
349    let mut parsed: Vec<ParsedFile> = Vec::new();
350    // T3.4 (R2.4): one `ExprId` counter across every file this project parse
351    // touches (every tree) — see `parse_sources`'s own doc comment for why a
352    // per-file counter would collide once `collect_unit_methods` merges
353    // sibling files' methods together.
354    let mut next_expr_id: u32 = 0;
355    // T3.5 (R2.2): one `FileId` counter across every file this project parse
356    // touches, mirroring `next_expr_id` above — see `parse_sources`'s own doc
357    // comment.
358    let mut next_file_id: u32 = 0;
359    let parse_tree = |root: &Path,
360                      prefix: &Path,
361                      files: &[PathBuf],
362                      parsed: &mut Vec<ParsedFile>,
363                      errors: &mut ErrorSink,
364                      snapshots: &mut Vec<(PathBuf, String)>,
365                      next_expr_id: &mut u32,
366                      next_file_id: &mut u32| {
367        for path in files {
368            // Tree-relative: what unit validation reads.
369            let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
370            // Slice 0 — project-relative: what *names* the file. Equal to `rel`
371            // for a single root (empty prefix).
372            let id = prefix.join(&rel);
373            let source = match read_source(path, overlay) {
374                Ok(s) => s,
375                Err(e) => {
376                    errors.push_for(
377                        Some(&id),
378                        CompileError::new(
379                            "bynk.project.read_failed",
380                            Span::default(),
381                            format!("could not read `{}`: {e}", path.display()),
382                        ),
383                    );
384                    continue;
385                }
386            };
387            snapshots.push((id.clone(), source.clone()));
388            match parse_sources(root, prefix, path, source, next_expr_id, next_file_id) {
389                Ok((pfs, warnings)) => {
390                    parsed.extend(pfs);
391                    // ADR 0117: the sink classifies these as warnings — they
392                    // surface with the build but never gate it.
393                    errors.extend_for(Some(&id), warnings);
394                }
395                Err(errs) => errors.extend_for(Some(&id), errs),
396            }
397        }
398    };
399    for ((root, prefix), files) in trees.iter().zip(file_lists.iter()) {
400        parse_tree(
401            root,
402            prefix,
403            files,
404            &mut parsed,
405            errors,
406            snapshots,
407            &mut next_expr_id,
408            &mut next_file_id,
409        );
410    }
411    if !errors.is_empty() && parsed.is_empty() {
412        return Err(());
413    }
414
415    // v0.17: if any user unit consumes the first-party `bynk` surface, inject it
416    // as a synthetic adapter so it flows through the normal pipeline (tables,
417    // exports, emission, compose). Its binding is supplied by the toolchain for
418    // the selected platform (§4.2). Injected only when consumed, so adapter-free
419    // projects are unchanged.
420    let consumes_bynk = parsed.iter().any(|pf| {
421        pf.consumes()
422            .iter()
423            .any(|c| c.target.joined() == firstparty::BYNK_UNIT)
424    });
425    if consumes_bynk {
426        static CACHE: OnceLock<Result<ParsedFile, Vec<CompileError>>> = OnceLock::new();
427        match firstparty_parsed(
428            &CACHE,
429            "bynk.bynk",
430            firstparty::BYNK_ADAPTER_SRC,
431            UnitKind::Adapter,
432            FIRSTPARTY_ID_BASE,
433        ) {
434            Ok(pf) => parsed.push(pf),
435            Err(errs) => errors.extend_for(None, errs),
436        }
437    }
438    // v0.19: likewise the first-party `bynk.cloudflare` platform adapter —
439    // injected only when consumed, binding supplied by the toolchain. The
440    // unit name sits inside the reserved `bynk.*` prefix (decision 0026).
441    let consumes_cloudflare = parsed.iter().any(|pf| {
442        pf.consumes()
443            .iter()
444            .any(|c| c.target.joined() == firstparty::CLOUDFLARE_UNIT)
445    });
446    if consumes_cloudflare {
447        static CACHE: OnceLock<Result<ParsedFile, Vec<CompileError>>> = OnceLock::new();
448        match firstparty_parsed(
449            &CACHE,
450            "bynk/cloudflare.bynk",
451            firstparty::CLOUDFLARE_ADAPTER_SRC,
452            UnitKind::Adapter,
453            FIRSTPARTY_ID_BASE + FIRSTPARTY_ID_BLOCK,
454        ) {
455            Ok(pf) => parsed.push(pf),
456            Err(errs) => errors.extend_for(None, errs),
457        }
458    }
459    // v0.20b: the first-party collection commons. Unlike the adapters above
460    // these are *library* units — plain Bynk commons of generic functions —
461    // imported via `uses` rather than `consumes`, and injected the same way
462    // so they flow through the ordinary commons pipeline (tables, uses
463    // resolution, emission). `bynk.map` itself `uses bynk.list`, so using
464    // the former injects both.
465    let uses_unit = |parsed: &[ParsedFile], unit: &str| {
466        parsed
467            .iter()
468            .any(|pf| pf.uses().iter().any(|u| u.target.joined() == unit))
469    };
470    let uses_map = uses_unit(&parsed, firstparty::MAP_UNIT);
471    // `bynk.locale` itself `uses bynk.list` and `uses bynk.string`; compute it
472    // up front so both injections below can OR it in the same way `uses_map`
473    // is OR'd into the `bynk.list` check.
474    let uses_locale = uses_unit(&parsed, firstparty::LOCALE_UNIT);
475    // `bynk.locale` itself now `uses bynk.locale.types` (locale-negotiation-
476    // slice-2 follow-up, #886 — split out so a context can reach `LocaleTag`
477    // without also reaching `bynk.locale`'s `render`), and the `bynk` adapter
478    // `uses bynk.locale.types` directly for `capability Locale`'s
479    // `LocaleTag` — so this needs the same `|| uses_locale` cascade `uses_map`
480    // gets from `bynk.map` into the `bynk.list` check just below.
481    let uses_locale_types = uses_locale || uses_unit(&parsed, firstparty::LOCALE_TYPES_UNIT);
482    if uses_map {
483        static CACHE: OnceLock<Result<ParsedFile, Vec<CompileError>>> = OnceLock::new();
484        match firstparty_parsed(
485            &CACHE,
486            "bynk/map.bynk",
487            firstparty::BYNK_MAP_SRC,
488            UnitKind::Commons,
489            FIRSTPARTY_ID_BASE + 2 * FIRSTPARTY_ID_BLOCK,
490        ) {
491            Ok(pf) => parsed.push(pf),
492            Err(errs) => errors.extend_for(None, errs),
493        }
494    }
495    if uses_map || uses_locale || uses_unit(&parsed, firstparty::LIST_UNIT) {
496        static CACHE: OnceLock<Result<ParsedFile, Vec<CompileError>>> = OnceLock::new();
497        match firstparty_parsed(
498            &CACHE,
499            "bynk/list.bynk",
500            firstparty::BYNK_LIST_SRC,
501            UnitKind::Commons,
502            FIRSTPARTY_ID_BASE + 3 * FIRSTPARTY_ID_BLOCK,
503        ) {
504            Ok(pf) => parsed.push(pf),
505            Err(errs) => errors.extend_for(None, errs),
506        }
507    }
508    // v0.22a: the first-party string commons — derived helpers over the
509    // built-in string kernel (ADR 0046).
510    if uses_locale || uses_unit(&parsed, firstparty::STRING_UNIT) {
511        static CACHE: OnceLock<Result<ParsedFile, Vec<CompileError>>> = OnceLock::new();
512        match firstparty_parsed(
513            &CACHE,
514            "bynk/string.bynk",
515            firstparty::BYNK_STRING_SRC,
516            UnitKind::Commons,
517            FIRSTPARTY_ID_BASE + 4 * FIRSTPARTY_ID_BLOCK,
518        ) {
519            Ok(pf) => parsed.push(pf),
520            Err(errs) => errors.extend_for(None, errs),
521        }
522    }
523    // Locale-negotiation-slice-2 follow-up (#886): the locale value types
524    // (`LocaleTag`/`MessageArg`/`Message`), split out to a dependency-free
525    // leaf so `bynk.bynk`'s own `uses` (for `capability Locale`'s
526    // `LocaleTag`) and a message-bundle commons's `uses bynk.locale` (for
527    // `render`) no longer have to be the same clause.
528    if uses_locale_types {
529        static CACHE: OnceLock<Result<ParsedFile, Vec<CompileError>>> = OnceLock::new();
530        match firstparty_parsed(
531            &CACHE,
532            "bynk/locale/types.bynk",
533            firstparty::BYNK_LOCALE_TYPES_SRC,
534            UnitKind::Commons,
535            FIRSTPARTY_ID_BASE + 5 * FIRSTPARTY_ID_BLOCK,
536        ) {
537            Ok(pf) => parsed.push(pf),
538            Err(errs) => errors.extend_for(None, errs),
539        }
540    }
541    // Locale capability track, slice 1 (#844): the bundle-free `render`
542    // helper and the `message`/`with*` builder API.
543    if uses_locale {
544        static CACHE: OnceLock<Result<ParsedFile, Vec<CompileError>>> = OnceLock::new();
545        match firstparty_parsed(
546            &CACHE,
547            "bynk/locale.bynk",
548            firstparty::BYNK_LOCALE_SRC,
549            UnitKind::Commons,
550            FIRSTPARTY_ID_BASE + 6 * FIRSTPARTY_ID_BLOCK,
551        ) {
552            Ok(pf) => parsed.push(pf),
553            Err(errs) => errors.extend_for(None, errs),
554        }
555    }
556
557    Ok((parsed, consumes_bynk, consumes_cloudflare))
558}
559
560/// The `include` tree that discovered `pf`, found by matching its absolute
561/// path against each tree's root — not `trees[0]` unconditionally, since
562/// R3.9 (#1113) lets a file live under any `include` tree, not just the
563/// first. Falls back to `trees[0]` for a `pf` with no `abs_path` (unreachable
564/// for a real adapter: only synthetic units, which never declare `binding`,
565/// go without one) or if it somehow matches none. The longest matching root
566/// wins, in case one `include` tree is nested inside another.
567///
568/// A `trees` root is only absolute when `Roots`'s own `project_root` is — a
569/// relative project root (`bynkc build .`, the ordinary CLI shape) leaves
570/// every tree root relative, while `pf.abs_path()` (`bynk-project`'s
571/// `parse_sources`, via `std::path::absolute`) is always absolute. Comparing
572/// them directly with `starts_with` would never match, silently collapsing
573/// this back to the `trees[0]` bug it exists to fix. Each root is resolved
574/// through the same `std::path::absolute` before comparing, matching
575/// `abs_path`'s own normalisation exactly rather than requiring the caller
576/// to have already absolutised `Roots::project_root`.
577pub fn tree_root_for<'a>(trees: &'a [(PathBuf, PathBuf)], pf: &ParsedFile) -> &'a Path {
578    let Some(abs) = pf.abs_path() else {
579        return trees[0].0.as_path();
580    };
581    trees
582        .iter()
583        .filter_map(|(root, _)| {
584            std::path::absolute(root)
585                .ok()
586                .map(|abs_root| (root, abs_root))
587        })
588        .filter(|(_, abs_root)| abs.starts_with(abs_root))
589        .max_by_key(|(root, _)| root.as_os_str().len())
590        .map(|(root, _)| root.as_path())
591        .unwrap_or_else(|| trees[0].0.as_path())
592}
593
594/// Phase 3: group the parsed units by qualified name (production units, unit
595/// tests, and integration suites tracked separately), run the per-directory
596/// and path/name consistency checks, enforce the reserved `bynk` namespace and
597/// the adapter `binding` rules, resolve each adapter's binding module, and fold
598/// the adapters' pinned npm dependencies. Pushes diagnostics into `errors` and
599/// returns the production `groups`/`kinds`, the `test`/`integration` groups, the
600/// resolved `adapter_bindings`, and the collected `npm_deps`.
601#[allow(clippy::type_complexity)]
602#[allow(clippy::too_many_arguments)]
603pub fn phase_group(
604    parsed: &[ParsedFile],
605    trees: &[(PathBuf, PathBuf)],
606    platform: Platform,
607    consumes_bynk: bool,
608    consumes_cloudflare: bool,
609    overlay: &HashMap<PathBuf, String>,
610    errors: &mut ErrorSink,
611) -> (
612    BTreeMap<String, Vec<usize>>,
613    BTreeMap<String, UnitKind>,
614    BTreeMap<String, Vec<usize>>,
615    BTreeMap<String, Vec<usize>>,
616    HashMap<String, AdapterBinding>,
617    std::collections::BTreeMap<String, String>,
618) {
619    // Tests (v0.7) are tracked separately from production units. Their
620    // `target` joined-name can intentionally coincide with a commons or
621    // context name; they don't enter the production groups/kinds maps.
622    let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
623    let mut kinds: BTreeMap<String, UnitKind> = BTreeMap::new();
624    let mut test_groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
625    // v0.16: integration tests are tracked by suite name, separately again from
626    // unit tests — their `name()` is the synthetic `integration <suite>`.
627    let mut integration_groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
628    for (i, pf) in parsed.iter().enumerate() {
629        let name = pf.unit().name().joined();
630        if pf.kind() == UnitKind::Integration {
631            integration_groups.entry(name).or_default().push(i);
632        } else if pf.kind() == UnitKind::Test {
633            test_groups.entry(name).or_default().push(i);
634        } else {
635            groups.entry(name.clone()).or_default().push(i);
636            kinds.entry(name).or_insert(pf.kind());
637        }
638    }
639    // #696: the consistency checks pair each error with the project-relative
640    // path of the file its primary span belongs to, so the CLI renders them
641    // with ariadne source context rather than the plain fallback.
642    if let Err(e) = check_directory_name_consistency(parsed) {
643        for (path, err) in e {
644            errors.push_for(Some(&path), err);
645        }
646    }
647    if let Err(e) = check_directory_kind_consistency(parsed) {
648        errors.extend_for(None, e);
649    }
650    // A group must agree on kind across all its files (different name but
651    // same kind is fine; same name but different kind is an error).
652    if let Err(e) = check_group_kind_consistency(parsed, &groups) {
653        for (path, err) in e {
654            errors.push_for(Some(&path), err);
655        }
656    }
657    // Each *source* unit's file path must match its declared qualified name.
658    // v0.113 (DECISION S): a `suite` has no path-identity requirement — it names
659    // its target and is legal in any file — so test-ness carries no path check.
660    if let Err(e) = check_path_name_alignment(parsed) {
661        for (path, err) in e {
662            errors.push_for(Some(&path), err);
663        }
664    }
665
666    // v0.20a: function types are confined to non-boundary positions. P5.2:
667    // this used to be an injected hook (`function_type_boundary_check`) so
668    // `run_checks` and the new analysis entry point could reach it without
669    // `bynk-check` reaching back into `bynk-emit`; now that the check lives
670    // here too, it's a direct call at the exact point the hook used to fire,
671    // preserving diagnostic order for both callers with no hook needed.
672    phase_function_type_boundaries(parsed, errors);
673
674    // v0.17: the `bynk` root namespace is reserved for the toolchain. No user
675    // unit of any kind may be named `bynk` or `bynk.*` (§3.4).
676    for pf in parsed {
677        if pf.is_synthetic() {
678            continue;
679        }
680        let qn = pf.unit().name();
681        if qn.parts.first().is_some_and(|p| p.name == "bynk") {
682            errors.push_for(Some(&pf.identity_path()),
683                CompileError::new(
684                    "bynk.namespace.reserved",
685                    qn.span,
686                    format!(
687                        "`{}` uses the reserved `bynk` namespace — the `bynk` root is reserved for the toolchain's conformance surface",
688                        qn.joined()
689                    ),
690                )
691                .with_note("rename the unit so its first segment is not `bynk`"),
692            );
693        }
694    }
695
696    // v0.17: an adapter that declares any external provider must name a
697    // `binding` module to supply the implementation symbols (§3.5). First-party
698    // (synthetic) adapters omit the clause — the toolchain supplies the binding.
699    for pf in parsed {
700        if pf.is_synthetic() {
701            continue;
702        }
703        if let Some(a) = pf.adapter() {
704            let has_external = a
705                .items
706                .iter()
707                .any(|it| matches!(it, CommonsItem::Provider(p) if p.external));
708            if has_external && a.binding.is_none() {
709                errors.push_for(Some(&pf.identity_path()),
710                    CompileError::new(
711                        "bynk.adapter.no_binding",
712                        a.span,
713                        format!(
714                            "adapter `{}` declares an external provider but has no `binding` clause to supply its implementation",
715                            a.name.joined()
716                        ),
717                    )
718                    .with_note(
719                        "add a `binding \"<module>\"` clause naming the TypeScript module that exports the provider symbols",
720                    ),
721                );
722            }
723        }
724    }
725
726    // v0.17: resolve each adapter's binding module (relative to the adapter's
727    // source file) and read it, so compose can import the external provider
728    // symbols and the binding is copied into the output for the `tsc` gate.
729    let mut adapter_bindings: HashMap<String, AdapterBinding> = HashMap::new();
730    // v0.17: the toolchain supplies the `bynk` surface's binding, platform-keyed.
731    if consumes_bynk {
732        adapter_bindings.insert(
733            firstparty::BYNK_UNIT.to_string(),
734            AdapterBinding {
735                output_path: PathBuf::from(platform.bynk_binding_filename()),
736                content: platform.bynk_binding_source().to_string(),
737            },
738        );
739    }
740    // v0.19: the platform adapter's binding is single — it runs only on its
741    // own platform (the lock check rejects other `--platform` selections).
742    if consumes_cloudflare {
743        adapter_bindings.insert(
744            firstparty::CLOUDFLARE_UNIT.to_string(),
745            AdapterBinding {
746                output_path: PathBuf::from(firstparty::CLOUDFLARE_BINDING_FILENAME),
747                content: firstparty::cloudflare_binding_source().to_string(),
748            },
749        );
750    }
751    for pf in parsed {
752        let Some(a) = pf.adapter() else { continue };
753        let Some(b) = &a.binding else { continue };
754        let pf_source_path = pf.source_path();
755        let adapter_dir = pf_source_path.parent().unwrap_or(Path::new(""));
756        let out_rel = normalize_rel(&adapter_dir.join(&b.module));
757        let src_abs = tree_root_for(trees, pf).join(&out_rel);
758        match read_adapter_binding(&src_abs, overlay) {
759            Ok(content) => {
760                adapter_bindings.insert(
761                    a.name.joined(),
762                    AdapterBinding {
763                        output_path: out_rel,
764                        content,
765                    },
766                );
767            }
768            Err(e) => {
769                errors.push_for(Some(&pf.identity_path()),
770                    CompileError::new(
771                        "bynk.adapter.no_binding",
772                        b.module_span,
773                        format!(
774                            "adapter `{}` names binding module `{}`, which could not be read ({e})",
775                            a.name.joined(),
776                            b.module
777                        ),
778                    )
779                    .with_note(
780                        "the binding path is resolved relative to the adapter's source file; author the `.binding.ts` there",
781                    ),
782                );
783            }
784        }
785    }
786
787    // v0.17: collect adapter npm dependencies for `package.json`, rejecting
788    // unpinned ranges ([DECISION L] stub — fold + pin-check only, no allow-list).
789    let mut npm_deps: std::collections::BTreeMap<String, String> =
790        std::collections::BTreeMap::new();
791    for pf in parsed {
792        let Some(a) = pf.adapter() else { continue };
793        let Some(b) = &a.binding else { continue };
794        for dep in &b.requires {
795            if is_unpinned_range(&dep.range) {
796                errors.push_for(Some(&pf.identity_path()),
797                    CompileError::new(
798                        "bynk.requires.unpinned_dependency",
799                        dep.span,
800                        format!(
801                            "dependency `{}` has an unpinned version range `{}` — pin a concrete range (e.g. `^1.2.0`)",
802                            dep.package, dep.range
803                        ),
804                    )
805                    .with_note(
806                        "unpinned ranges (`*`, `latest`, …) make builds irreproducible and are rejected",
807                    ),
808                );
809                continue;
810            }
811            npm_deps.insert(dep.package.clone(), dep.range.clone());
812        }
813    }
814
815    (
816        groups,
817        kinds,
818        test_groups,
819        integration_groups,
820        adapter_bindings,
821        npm_deps,
822    )
823}
824
825/// v0.20a: apply the function-type boundary confinement to every serialisable
826/// or boundary-crossing position in a file's items: record fields and sum
827/// payloads (types can cross contexts and persist), service/agent handler
828/// signatures (the Workers wire), capability operation signatures (kept out
829/// in v0.20a — see ADR 0030), agent state fields, and agent keys. Free `fn`
830/// signatures are deliberately NOT walked — they are the non-boundary home
831/// of function types.
832///
833/// #696: each diagnostic is paired with the project-relative `identity_path` of
834/// the file whose items produced it, so the CLI renders it against that file's
835/// source.
836///
837/// P5.2 (`design/tracks/semantics-in-the-checker.md` §6): relocated verbatim
838/// from `bynk-emit/src/project/validate.rs`'s `check_function_type_boundaries`
839/// — category 6 of `analysis.rs`'s own seven-category accounting. Previously
840/// reached only through `phase_group`'s optional `function_type_boundary_check`
841/// hook (`Some` from `run_checks`, `None` from the new entry point); that hook
842/// is gone — [`phase_group`] itself now calls this function directly, at the
843/// exact point the hook used to fire, so both callers see it in the same
844/// diagnostic-ordering position as before and can no longer drift on whether
845/// the check runs at all.
846pub fn phase_function_type_boundaries(parsed: &[ParsedFile], errors: &mut ErrorSink) {
847    // v0.174 (#592): the boundary check now also rejects a *recursive* generic
848    // record (`reject_fn_types`' `App` arm), which needs the type declarations to
849    // walk the containment graph. Build the project-wide table once — a generic
850    // referenced from one file may be declared in another.
851    let types = collect_type_decls(parsed.iter().flat_map(|pf| pf.items()));
852    for pf in parsed {
853        let mut file_errors: Vec<CompileError> = Vec::new();
854        check_function_type_boundary_items(pf.items(), &types, &mut file_errors);
855        for err in file_errors {
856            errors.push_for(Some(&pf.identity_path()), err);
857        }
858    }
859}
860
861/// v0.174 (#592): a `name -> TypeDecl` table over a set of items, for the
862/// recursive-generic boundary walk. Relocated alongside
863/// `phase_function_type_boundaries` (P5.2) — public since `bynk-emit`'s
864/// single-file compile path (`lib.rs`) also needs it, across the crate
865/// boundary this relocation now draws.
866pub fn collect_type_decls<'a>(
867    items: impl Iterator<Item = &'a CommonsItem>,
868) -> HashMap<String, Arc<TypeDecl>> {
869    let mut out = HashMap::new();
870    for item in items {
871        match item {
872            CommonsItem::Type(t) => {
873                out.entry(t.name.name.clone())
874                    .or_insert_with(|| Arc::new(t.clone()));
875            }
876            // Events track, slice 0 (spine #936): an event's synthetic
877            // `TypeDecl` joins the same table, so a field referencing an
878            // event type recurses into it exactly like any other type.
879            CommonsItem::Event(e) => {
880                out.entry(e.name.name.clone())
881                    .or_insert_with(|| Arc::new(e.as_type_decl()));
882            }
883            _ => {}
884        }
885    }
886    out
887}
888
889/// Item-level body of the boundary confinement, shared with the single-file
890/// (legacy) compile path in `bynk-emit`'s `lib.rs`. Relocated alongside
891/// `phase_function_type_boundaries` (P5.2).
892pub fn check_function_type_boundary_items(
893    items: &[CommonsItem],
894    types: &HashMap<String, Arc<TypeDecl>>,
895    errors: &mut Vec<CompileError>,
896) {
897    for item in items {
898        match item {
899            CommonsItem::Type(t) => match &t.body {
900                TypeBody::Record(r) => {
901                    for f in &r.fields {
902                        reject_fn_types(&f.type_ref, "a record field", types, errors);
903                    }
904                }
905                TypeBody::Sum(s) => {
906                    for v in &s.variants {
907                        for p in &v.payload {
908                            reject_fn_types(&p.type_ref, "a sum-variant payload", types, errors);
909                        }
910                    }
911                }
912                TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
913            },
914            // Events track, slice 0 (spine #936): an event's fields are
915            // boundary values (an emission crosses a context boundary),
916            // so the same record-field rule applies as for a `type`.
917            CommonsItem::Event(e) => {
918                for f in &e.body.fields {
919                    reject_fn_types(&f.type_ref, "an event field", types, errors);
920                }
921            }
922            CommonsItem::Capability(c) => {
923                for op in &c.ops {
924                    for p in &op.params {
925                        reject_fn_types(
926                            &p.type_ref,
927                            "a capability operation signature",
928                            types,
929                            errors,
930                        );
931                    }
932                    // v0.102 (§2.9.1): a capability operation may *produce* a
933                    // held value — it is the canonical held source — so an
934                    // `Effect[Connection[F]]` return is admitted.
935                    if !type_ref_is_held(&op.return_type) {
936                        reject_fn_types(
937                            &op.return_type,
938                            "a capability operation signature",
939                            types,
940                            errors,
941                        );
942                    }
943                }
944            }
945            CommonsItem::Service(s) => {
946                for h in &s.handlers {
947                    for p in &h.params {
948                        // v0.102 (§2.9.4): the framework may supply a held
949                        // value as a handler parameter (the `on open`
950                        // connection), so a `Connection[F]` parameter is
951                        // admitted.
952                        if !type_ref_is_held(&p.type_ref) {
953                            reject_fn_types(
954                                &p.type_ref,
955                                "a service handler signature",
956                                types,
957                                errors,
958                            );
959                        }
960                    }
961                    reject_fn_types(&h.return_type, "a service handler signature", types, errors);
962                }
963            }
964            CommonsItem::Agent(a) => {
965                reject_fn_types(&a.key_type, "an agent key", types, errors);
966                for f in &a.store_fields {
967                    validate_store_field_value_types(f, types, errors);
968                }
969                for h in &a.handlers {
970                    for p in &h.params {
971                        // v0.102 (§2.9.4): a held value may be transferred to
972                        // an agent handler as a parameter.
973                        if !type_ref_is_held(&p.type_ref) {
974                            reject_fn_types(
975                                &p.type_ref,
976                                "an agent handler signature",
977                                types,
978                                errors,
979                            );
980                        }
981                    }
982                    reject_fn_types(&h.return_type, "an agent handler signature", types, errors);
983                }
984            }
985            CommonsItem::Actor(a) => {
986                if let Some(id) = &a.identity {
987                    reject_fn_types(id, "an actor identity type", types, errors);
988                }
989            }
990            // slice 1: `MessageEntry.code`/`.template` are plain string
991            // literals, no fn-type-bearing fields to reject here.
992            CommonsItem::Fn(_) | CommonsItem::Provider(_) | CommonsItem::Messages(_) => {}
993        }
994    }
995}
996
997/// Phase 4: build each production unit's combined symbol table from its files,
998/// pushing any table-construction errors into `errors`.
999pub fn phase_symbol_tables(
1000    groups: &BTreeMap<String, Vec<usize>>,
1001    kinds: &BTreeMap<String, UnitKind>,
1002    parsed: &[ParsedFile],
1003    errors: &mut ErrorSink,
1004) -> HashMap<String, UnitTable> {
1005    let mut unit_tables: HashMap<String, UnitTable> = HashMap::new();
1006    for (name, indices) in groups {
1007        let kind = *kinds.get(name).expect("every group has a kind");
1008        // #696: build_unit_table pairs each diagnostic with its declaring file.
1009        let mut table_errors: Vec<(PathBuf, CompileError)> = Vec::new();
1010        let table = build_unit_table(name, kind, indices, parsed, &mut table_errors);
1011        for (path, err) in table_errors {
1012            errors.push_for(Some(&path), err);
1013        }
1014        unit_tables.insert(name.clone(), table);
1015    }
1016    unit_tables
1017}
1018
1019/// Phase 5: resolve each unit's `uses` clauses, checking the target exists, is
1020/// a commons, and is not self-referential. Returns unit → deduplicated list of
1021/// used commons; diagnostics go into `errors`.
1022pub fn phase_resolve_uses(
1023    groups: &BTreeMap<String, Vec<usize>>,
1024    kinds: &BTreeMap<String, UnitKind>,
1025    parsed: &[ParsedFile],
1026    unit_tables: &HashMap<String, UnitTable>,
1027    errors: &mut ErrorSink,
1028) -> HashMap<String, Vec<String>> {
1029    let mut unit_uses: HashMap<String, Vec<String>> = HashMap::new();
1030    for (name, indices) in groups {
1031        let mut uses_targets: Vec<String> = Vec::new();
1032        for &i in indices {
1033            for u in parsed[i].uses() {
1034                let target = u.target.joined();
1035                if !unit_tables.contains_key(&target) {
1036                    errors.push_for(
1037                        Some(&parsed[i].identity_path()),
1038                        CompileError::new(
1039                            "bynk.uses.unknown_commons",
1040                            u.span,
1041                            format!("unknown commons `{target}`"),
1042                        )
1043                        .with_note(
1044                            "the target of a `uses` clause must be a commons in the project",
1045                        ),
1046                    );
1047                    continue;
1048                }
1049                let target_kind = *kinds.get(&target).unwrap();
1050                if target_kind != UnitKind::Commons {
1051                    errors.push_for(Some(&parsed[i].identity_path()),
1052                        CompileError::new(
1053                            "bynk.uses.target_is_context",
1054                            u.span,
1055                            format!(
1056                                "`uses {target}` targets a context — `uses` may only target a commons"
1057                            ),
1058                        )
1059                        .with_note(
1060                            "to declare a dependency on a context, use `consumes` instead",
1061                        ),
1062                    );
1063                    continue;
1064                }
1065                if target == *name {
1066                    errors.push_for(
1067                        Some(&parsed[i].identity_path()),
1068                        CompileError::new(
1069                            "bynk.uses.self_reference",
1070                            u.span,
1071                            format!("`{name}` cannot `uses` itself"),
1072                        ),
1073                    );
1074                    continue;
1075                }
1076                if !uses_targets.contains(&target) {
1077                    uses_targets.push(target);
1078                }
1079            }
1080        }
1081        unit_uses.insert(name.clone(), uses_targets);
1082    }
1083    unit_uses
1084}
1085
1086/// Phase 5b: resolve each unit's `consumes` clauses (target exists, is a context
1087/// or adapter, not self-referential, obeys the adapter selection rules), and for
1088/// the braced `consumes U { Cap, … }` form validate and record the flattened
1089/// capabilities. Returns unit → consumed targets and unit → flattened-cap → owning
1090/// unit; diagnostics go into `errors` and clause-position references into `refs`.
1091#[allow(clippy::type_complexity)]
1092pub fn phase_resolve_consumes(
1093    groups: &BTreeMap<String, Vec<usize>>,
1094    kinds: &BTreeMap<String, UnitKind>,
1095    parsed: &[ParsedFile],
1096    unit_tables: &HashMap<String, UnitTable>,
1097    errors: &mut ErrorSink,
1098    refs: &mut RefSink,
1099) -> (
1100    HashMap<String, Vec<String>>,
1101    HashMap<String, HashMap<String, String>>,
1102) {
1103    let mut unit_consumes: HashMap<String, Vec<String>> = HashMap::new();
1104    // v0.17: `consumes U { Cap, … }` flattens selected caps into the consumer's
1105    // local namespace. unit → bare-cap → consumed unit providing it.
1106    let mut unit_flattened: HashMap<String, HashMap<String, String>> = HashMap::new();
1107    for (name, indices) in groups {
1108        let kind = *kinds.get(name).unwrap();
1109        let mut consumes_targets: Vec<String> = Vec::new();
1110        let mut flattened: HashMap<String, String> = HashMap::new();
1111        let local_caps: HashSet<String> = unit_tables
1112            .get(name)
1113            .map(|t| t.capabilities.keys().cloned().collect())
1114            .unwrap_or_default();
1115        for &i in indices {
1116            refs.enter_file(&parsed[i].identity_path(), name, parsed[i].is_synthetic());
1117            for c in parsed[i].consumes() {
1118                let target = c.target.joined();
1119                if kind != UnitKind::Context && kind != UnitKind::Adapter {
1120                    errors.push_for(Some(&parsed[i].identity_path()),
1121                        CompileError::new(
1122                            "bynk.consumes.in_commons",
1123                            c.span,
1124                            format!(
1125                                "`consumes` is only valid inside a context or adapter, not a commons `{name}`",
1126                            ),
1127                        )
1128                        .with_note(
1129                            "commons declare vocabulary; only contexts and adapters can declare behavioural dependencies",
1130                        ),
1131                    );
1132                    continue;
1133                }
1134                // v0.18: an adapter's `consumes` is the braced capability-selection
1135                // form only — an adapter has no services to RPC-call, so the
1136                // whole-unit and `as Alias` forms are meaningless inside one.
1137                if kind == UnitKind::Adapter && c.selected.is_none() {
1138                    errors.push_for(Some(&parsed[i].identity_path()),
1139                        CompileError::new(
1140                            "bynk.adapter.consumes_requires_selection",
1141                            c.span,
1142                            format!(
1143                                "an adapter's `consumes` must select capabilities — write `consumes {target} {{ Cap, … }}`",
1144                            ),
1145                        )
1146                        .with_note(
1147                            "adapters depend on capabilities, never on services; the whole-unit and aliased forms are context-only",
1148                        ),
1149                    );
1150                    continue;
1151                }
1152                if !unit_tables.contains_key(&target) {
1153                    errors.push_for(
1154                        Some(&parsed[i].identity_path()),
1155                        CompileError::new(
1156                            "bynk.consumes.unknown_context",
1157                            c.span,
1158                            format!("unknown context `{target}`"),
1159                        )
1160                        .with_note(
1161                            "the target of a `consumes` clause must be a context in the project",
1162                        ),
1163                    );
1164                    continue;
1165                }
1166                let target_kind = *kinds.get(&target).unwrap();
1167                // v0.17: `consumes` may target a context or an adapter (the host
1168                // boundary). It may not target a commons (use `uses` for that).
1169                if target_kind != UnitKind::Context && target_kind != UnitKind::Adapter {
1170                    errors.push_for(Some(&parsed[i].identity_path()),
1171                        CompileError::new(
1172                            "bynk.consumes.target_is_commons",
1173                            c.span,
1174                            format!(
1175                                "`consumes {target}` targets a commons — `consumes` may only target a context or adapter"
1176                            ),
1177                        )
1178                        .with_note(
1179                            "to mix in declarations from a commons, use `uses` instead",
1180                        ),
1181                    );
1182                    continue;
1183                }
1184                // v0.18: adapter dependencies are adapter-to-adapter (spec §4.5) —
1185                // an adapter consuming a *context* would pull service logic into
1186                // the host boundary.
1187                if kind == UnitKind::Adapter && target_kind == UnitKind::Context {
1188                    errors.push_for(Some(&parsed[i].identity_path()),
1189                        CompileError::new(
1190                            "bynk.adapter.consumes_context",
1191                            c.span,
1192                            format!(
1193                                "adapter `{name}` cannot `consumes` the context `{target}` — adapter dependencies are adapter-to-adapter"
1194                            ),
1195                        )
1196                        .with_note(
1197                            "an adapter may only depend on capabilities exported by other adapters (e.g. the `bynk` surface)",
1198                        ),
1199                    );
1200                    continue;
1201                }
1202                if target == *name {
1203                    let kind_word = if kind == UnitKind::Adapter {
1204                        "adapter"
1205                    } else {
1206                        "context"
1207                    };
1208                    errors.push_for(
1209                        Some(&parsed[i].identity_path()),
1210                        CompileError::new(
1211                            "bynk.consumes.self_reference",
1212                            c.span,
1213                            format!("{kind_word} `{name}` cannot `consumes` itself"),
1214                        ),
1215                    );
1216                    continue;
1217                }
1218                // v0.17: `consumes U { Cap, … }` — validate each selected name is
1219                // a capability `U` exports, detect clashes, and record the
1220                // flattening so bare `given Cap` resolves through the local path.
1221                if let Some(names) = &c.selected {
1222                    let exported = unit_tables
1223                        .get(&target)
1224                        .map(|t| &t.exported_capabilities)
1225                        .cloned()
1226                        .unwrap_or_default();
1227                    for cap in names {
1228                        if !exported.contains(&cap.name) {
1229                            errors.push_for(
1230                                Some(&parsed[i].identity_path()),
1231                                CompileError::new(
1232                                    "bynk.given.cross_context_unknown_capability",
1233                                    cap.span,
1234                                    format!(
1235                                        "`{target}` does not export a capability named `{}`",
1236                                        cap.name
1237                                    ),
1238                                ),
1239                            );
1240                            continue;
1241                        }
1242                        if local_caps.contains(&cap.name) {
1243                            errors.push_for(Some(&parsed[i].identity_path()), CompileError::new(
1244                                "bynk.consumes.capability_name_clash",
1245                                cap.span,
1246                                format!(
1247                                    "flattened capability `{}` clashes with a capability declared locally — use qualified `given {target}.{}` instead",
1248                                    cap.name, cap.name
1249                                ),
1250                            ));
1251                            continue;
1252                        }
1253                        if let Some(prev) = flattened.get(&cap.name) {
1254                            errors.push_for(Some(&parsed[i].identity_path()), CompileError::new(
1255                                "bynk.consumes.capability_name_clash",
1256                                cap.span,
1257                                format!(
1258                                    "capability `{}` is flattened from both `{prev}` and `{target}` — qualify one with `given U.{}`",
1259                                    cap.name, cap.name
1260                                ),
1261                            ));
1262                            continue;
1263                        }
1264                        // v0.25: the selection list names the capability in
1265                        // the consumed unit (clause-position reference).
1266                        refs.record_in_unit(cap.span, SymbolKind::Capability, &cap.name, &target);
1267                        flattened.insert(cap.name.clone(), target.clone());
1268                    }
1269                }
1270                if !consumes_targets.contains(&target) {
1271                    consumes_targets.push(target);
1272                }
1273            }
1274        }
1275        unit_consumes.insert(name.clone(), consumes_targets);
1276        unit_flattened.insert(name.clone(), flattened);
1277    }
1278    (unit_consumes, unit_flattened)
1279}
1280
1281/// Phases 5b'/5b'': collect each context's `consumes` aliases (alias →
1282/// consumed-context name), reporting alias-vs-alias conflicts (5b'), then report
1283/// any alias that clashes with a locally-declared type/fn/capability/service/agent
1284/// (5b''). Returns the per-context alias maps; diagnostics go into `errors`.
1285pub fn phase_consumes_aliases(
1286    groups: &BTreeMap<String, Vec<usize>>,
1287    kinds: &BTreeMap<String, UnitKind>,
1288    parsed: &[ParsedFile],
1289    unit_tables: &HashMap<String, UnitTable>,
1290    errors: &mut ErrorSink,
1291) -> HashMap<String, HashMap<String, String>> {
1292    let mut unit_consumes_aliases: HashMap<String, HashMap<String, String>> = HashMap::new();
1293    for (name, indices) in groups {
1294        let kind = *kinds.get(name).unwrap();
1295        if kind != UnitKind::Context {
1296            continue;
1297        }
1298        let mut aliases: HashMap<String, String> = HashMap::new();
1299        let mut alias_spans: HashMap<String, Span> = HashMap::new();
1300        for &i in indices {
1301            for c in parsed[i].consumes() {
1302                let Some(alias) = &c.alias else { continue };
1303                let target = c.target.joined();
1304                if !unit_tables.contains_key(&target) {
1305                    // Already reported as unknown context above.
1306                    continue;
1307                }
1308                if let Some(prev_span) = alias_spans.get(&alias.name) {
1309                    errors.push_for(Some(&parsed[i].identity_path()),
1310                        CompileError::new(
1311                            "bynk.consumes.alias_conflict",
1312                            alias.span,
1313                            format!(
1314                                "alias `{}` is used by more than one `consumes` clause in context `{}`",
1315                                alias.name, name
1316                            ),
1317                        )
1318                        .with_label(*prev_span, "previously defined here")
1319                        .with_note(
1320                            "each `consumes` clause may introduce at most one alias, and aliases must be unique within a context",
1321                        ),
1322                    );
1323                    continue;
1324                }
1325                aliases.insert(alias.name.clone(), target);
1326                alias_spans.insert(alias.name.clone(), alias.span);
1327            }
1328        }
1329        unit_consumes_aliases.insert(name.clone(), aliases);
1330    }
1331
1332    // -- 5b''. Detect alias-vs-local-decl conflicts. An alias must not clash
1333    //          with any locally declared type/fn/capability/service/agent.
1334    for (name, aliases) in &unit_consumes_aliases {
1335        let Some(local) = unit_tables.get(name) else {
1336            continue;
1337        };
1338        for alias in aliases.keys() {
1339            let alias_site = parsed_alias_span(parsed, &groups[name], alias);
1340            let alias_span = alias_site.map(|(_, s)| s).unwrap_or_default();
1341            let alias_file = alias_site.map(|(i, _)| parsed[i].identity_path());
1342            let conflict_kind = if local.types.contains_key(alias) {
1343                Some("type")
1344            } else if local.fns.contains_key(alias) {
1345                Some("function")
1346            } else if local.capabilities.contains_key(alias) {
1347                Some("capability")
1348            } else if local.services.contains_key(alias) {
1349                Some("service")
1350            } else if local.agents.contains_key(alias) {
1351                Some("agent")
1352            } else {
1353                None
1354            };
1355            if let Some(kind) = conflict_kind {
1356                errors.push_for(alias_file.as_deref(),
1357                    CompileError::new(
1358                        "bynk.consumes.alias_conflict",
1359                        alias_span,
1360                        format!(
1361                            "alias `{alias}` conflicts with a local {kind} of the same name in context `{name}`",
1362                        ),
1363                    )
1364                    .with_note(
1365                        "pick a different alias for the `consumes` clause, or rename the local declaration",
1366                    ),
1367                );
1368            }
1369        }
1370    }
1371    unit_consumes_aliases
1372}
1373
1374/// Phase 6: for each unit, detect when two `uses`-imported commons declare the
1375/// same (non-shadowed) type or function name — an unrenamable conflict at the use
1376/// site. Diagnostics go into `errors`.
1377pub fn phase_uses_name_conflicts(
1378    unit_uses: &HashMap<String, Vec<String>>,
1379    unit_tables: &HashMap<String, UnitTable>,
1380    parsed: &[ParsedFile],
1381    groups: &BTreeMap<String, Vec<usize>>,
1382    errors: &mut ErrorSink,
1383) {
1384    for (name, targets) in unit_uses {
1385        let local = unit_tables.get(name).expect("unit table present");
1386        let mut imported: HashMap<String, String> = HashMap::new();
1387        for t in targets {
1388            let used = unit_tables.get(t).expect("used unit table present");
1389            for type_name in used.types.keys() {
1390                if local.types.contains_key(type_name) || local.fns.contains_key(type_name) {
1391                    continue;
1392                }
1393                if let Some(prev) = imported.get(type_name) {
1394                    let site = uses_span_of(parsed, &groups[name], t);
1395                    let span = site.map(|(_, s)| s).unwrap_or_default();
1396                    let file = site.map(|(i, _)| parsed[i].identity_path());
1397                    errors.push_for(file.as_deref(),
1398                        CompileError::new(
1399                            "bynk.uses.name_conflict",
1400                            span,
1401                            format!(
1402                                "`{name}` uses two commons that both declare `{type_name}`: `{prev}` and `{t}`",
1403                            ),
1404                        )
1405                        .with_note(
1406                            "name conflicts at the use site are not yet renamable; remove or restructure one of the imports",
1407                        ),
1408                    );
1409                } else {
1410                    imported.insert(type_name.clone(), t.clone());
1411                }
1412            }
1413            for fn_name in used.fns.keys() {
1414                if local.types.contains_key(fn_name) || local.fns.contains_key(fn_name) {
1415                    continue;
1416                }
1417                if let Some(prev) = imported.get(fn_name) {
1418                    let site = uses_span_of(parsed, &groups[name], t);
1419                    let span = site.map(|(_, s)| s).unwrap_or_default();
1420                    let file = site.map(|(i, _)| parsed[i].identity_path());
1421                    errors.push_for(file.as_deref(),
1422                        CompileError::new(
1423                            "bynk.uses.name_conflict",
1424                            span,
1425                            format!(
1426                                "`{name}` uses two commons that both declare `{fn_name}`: `{prev}` and `{t}`",
1427                            ),
1428                        )
1429                        .with_note(
1430                            "name conflicts at the use site are not yet renamable; remove or restructure one of the imports",
1431                        ),
1432                    );
1433                } else {
1434                    imported.insert(fn_name.clone(), t.clone());
1435                }
1436            }
1437        }
1438    }
1439}
1440
1441/// message-bundles slice 1 (#859): messages-block legality, `@reference`
1442/// cardinality, within-block duplicate codes, and the `uses bynk.locale`
1443/// dependency. Runs here (not in `phase_group`) because it needs `unit_uses`,
1444/// resolved just above.
1445///
1446/// P5.0 (`design/tracks/semantics-in-the-checker.md` §6): relocated verbatim
1447/// from `bynk-emit/src/project/validate.rs`'s `check_messages_bundles` — one
1448/// of the two live editor-diagnostics regressions this slice closes (category
1449/// 2 of `analysis.rs`'s own seven-category accounting). Cross-locale
1450/// completeness (`bynk.messages.incomplete`, only for codes present in the
1451/// reference locale but not this one — a locale-specific-only code is not an
1452/// error, per the "reference is a floor, not a ceiling" convention) and
1453/// cross-locale placeholder-*set* agreement (`bynk.messages.placeholder_mismatch`,
1454/// only for codes present in both — a missing code is `incomplete`'s job, not
1455/// this one's). Two blocks declaring the same locale tag are rejected outright
1456/// (`bynk.resolve.duplicate_message_locale`, PR #875 review) — the emitter
1457/// has no dedup of its own, so a silent last-wins here would let a hard
1458/// `tsc` redeclare error (two colliding `const __messages_<tag>`
1459/// declarations) through instead.
1460pub fn phase_messages_bundles(
1461    parsed: &[ParsedFile],
1462    groups: &BTreeMap<String, Vec<usize>>,
1463    kinds: &BTreeMap<String, UnitKind>,
1464    unit_uses: &HashMap<String, Vec<String>>,
1465    errors: &mut ErrorSink,
1466) {
1467    for (name, indices) in groups {
1468        let mut first_messages: Option<(usize, Span)> = None;
1469        let mut reference_sites: Vec<(usize, Span)> = Vec::new();
1470        let mut reference_block: Option<(usize, &MessagesDecl)> = None;
1471        let mut by_tag: HashMap<&str, (usize, &MessagesDecl)> = HashMap::new();
1472        for &i in indices {
1473            for item in parsed[i].items() {
1474                let CommonsItem::Messages(m) = item else {
1475                    continue;
1476                };
1477                if first_messages.is_none() {
1478                    first_messages = Some((i, m.span));
1479                }
1480                if kinds.get(name) != Some(&UnitKind::Commons) {
1481                    errors.push_for(
1482                        Some(&parsed[i].identity_path()),
1483                        CompileError::new(
1484                            "bynk.messages.outside_commons",
1485                            m.span,
1486                            "`messages` declarations are only allowed inside a commons, not a context or adapter",
1487                        ),
1488                    );
1489                    continue;
1490                }
1491                // #899: the tag is a `LocaleTag` string literal, checked here
1492                // against `LocaleTag`'s own refinement (read from the
1493                // firstparty `bynk.locale.types` source, so the pattern has one
1494                // definition). An invalid tag would otherwise reach `Intl` at
1495                // runtime as `new Intl.PluralRules("xx")`, which throws — the
1496                // opposite of `render`'s totality contract.
1497                if !checker::locale_tag_accepts(&m.tag) {
1498                    let pattern = checker::locale_tag_pattern().unwrap_or("");
1499                    errors.push_for(
1500                        Some(&parsed[i].identity_path()),
1501                        CompileError::new(
1502                            "bynk.messages.invalid_locale_tag",
1503                            m.tag_span,
1504                            format!(
1505                                "\"{}\" is not a valid `LocaleTag` — it must match the pattern `{}`",
1506                                m.tag, pattern
1507                            ),
1508                        ),
1509                    );
1510                }
1511                // message-bundles slice 2 (#874, PR #875 review): two blocks
1512                // declaring the same locale tag are rejected, not
1513                // last-write-wins — the emitter (`emit_messages_bundle`) has no
1514                // dedup of its own and would emit two colliding table entries
1515                // under one object key, a hard `tsc` error. Mirrors
1516                // `bynk.resolve.duplicate_fn`'s own shape: only the *first*
1517                // occurrence seeds `by_tag`, so a third duplicate still reports
1518                // against the original, not the second.
1519                if let Some(&(_, prev)) = by_tag.get(m.tag.as_str()) {
1520                    errors.push_for(
1521                        Some(&parsed[i].identity_path()),
1522                        CompileError::new(
1523                            "bynk.resolve.duplicate_message_locale",
1524                            m.tag_span,
1525                            format!("locale \"{}\" is already declared in this bundle", m.tag),
1526                        )
1527                        .with_label(prev.tag_span, "previously declared here"),
1528                    );
1529                } else {
1530                    by_tag.insert(m.tag.as_str(), (i, m));
1531                }
1532                for ann in &m.annotations {
1533                    if ann.name.name == "reference" {
1534                        reference_sites.push((i, ann.span));
1535                        reference_block = Some((i, m));
1536                    }
1537                }
1538                let mut seen: HashMap<&str, Span> = HashMap::new();
1539                for entry in &m.entries {
1540                    if let Some(prev) = seen.get(entry.code.as_str()) {
1541                        errors.push_for(
1542                            Some(&parsed[i].identity_path()),
1543                            CompileError::new(
1544                                "bynk.resolve.duplicate_message_code",
1545                                entry.code_span,
1546                                format!(
1547                                    "message code \"{}\" is already declared in this block",
1548                                    entry.code
1549                                ),
1550                            )
1551                            .with_label(*prev, "previously declared here"),
1552                        );
1553                    } else {
1554                        seen.insert(entry.code.as_str(), entry.code_span);
1555                    }
1556                    // message-bundles slice 3 (#878): runs unconditionally,
1557                    // once per entry, regardless of `@reference` cardinality
1558                    // — malformed ICU syntax shouldn't wait on cardinality
1559                    // being resolved first.
1560                    check_entry_icu_syntax(entry, Some(&parsed[i].identity_path()), errors);
1561                }
1562            }
1563        }
1564        let Some((first_i, first_span)) = first_messages else {
1565            continue;
1566        };
1567        if kinds.get(name) != Some(&UnitKind::Commons) {
1568            // Already reported above (outside_commons) for every block;
1569            // cardinality/uses checks don't apply to a non-commons unit.
1570            continue;
1571        }
1572        match reference_sites.len() {
1573            0 => {
1574                errors.push_for(
1575                    Some(&parsed[first_i].identity_path()),
1576                    CompileError::new(
1577                        "bynk.messages.missing_reference",
1578                        first_span,
1579                        "a message bundle must have exactly one `@reference` block; none found",
1580                    ),
1581                );
1582            }
1583            1 => {
1584                // message-bundles slice 2 (#874): "the reference" is only
1585                // well-defined here — 0 or 2+ already reported their own
1586                // diagnostic above, and completeness/placeholder-agreement
1587                // against an ambiguous or absent reference would be noise.
1588                let (_, reference) = reference_block
1589                    .expect("reference_sites.len() == 1 implies reference_block is Some");
1590                // Sorted for deterministic diagnostic order — `by_tag`'s
1591                // HashMap iteration is not otherwise stable across runs.
1592                let mut sorted_tags: Vec<&&str> = by_tag.keys().collect();
1593                sorted_tags.sort();
1594                for &&tag in &sorted_tags {
1595                    let &(locale_i, locale_m) = &by_tag[tag];
1596                    if tag == reference.tag.as_str() {
1597                        continue;
1598                    }
1599                    for ref_entry in &reference.entries {
1600                        let Some(locale_entry) =
1601                            locale_m.entries.iter().find(|e| e.code == ref_entry.code)
1602                        else {
1603                            errors.push_for(
1604                                Some(&parsed[locale_i].identity_path()),
1605                                CompileError::new(
1606                                    "bynk.messages.incomplete",
1607                                    locale_m.span,
1608                                    format!(
1609                                        "locale \"{tag}\" is missing code \"{}\", declared by the reference locale \"{}\"",
1610                                        ref_entry.code, reference.tag
1611                                    ),
1612                                ),
1613                            );
1614                            continue;
1615                        };
1616                        let ref_names = icu::placeholder_names(&ref_entry.template);
1617                        let locale_names = icu::placeholder_names(&locale_entry.template);
1618                        if ref_names != locale_names {
1619                            errors.push_for(
1620                                Some(&parsed[locale_i].identity_path()),
1621                                CompileError::new(
1622                                    "bynk.messages.placeholder_mismatch",
1623                                    locale_entry.template_span,
1624                                    format!(
1625                                        "locale \"{tag}\"'s template for code \"{}\" uses placeholders {locale_names:?}, but the reference locale \"{}\"'s uses {ref_names:?}",
1626                                        ref_entry.code, reference.tag
1627                                    ),
1628                                ),
1629                            );
1630                        }
1631                        // message-bundles slice 3 (#878, Decision D): a name
1632                        // present in both templates must also agree on ICU
1633                        // format *kind* (plain/plural/select/number/date) —
1634                        // a UI can't sanely alternate that per locale. A
1635                        // missing name is `placeholder_mismatch`'s job, not
1636                        // this one's; a malformed template's kinds are
1637                        // silently absent from `template_format_kinds`
1638                        // (already reported once by `check_entry_icu_syntax`
1639                        // above, never double-reported here).
1640                        let ref_kinds = icu::template_format_kinds(&ref_entry.template);
1641                        let locale_kinds = icu::template_format_kinds(&locale_entry.template);
1642                        for (pname, ref_kind) in &ref_kinds {
1643                            let Some(locale_kind) = locale_kinds.get(pname) else {
1644                                continue;
1645                            };
1646                            if locale_kind != ref_kind {
1647                                errors.push_for(
1648                                    Some(&parsed[locale_i].identity_path()),
1649                                    CompileError::new(
1650                                        "bynk.messages.format_mismatch",
1651                                        locale_entry.template_span,
1652                                        format!(
1653                                            "locale \"{tag}\"'s placeholder \"{pname}\" in code \"{}\" is formatted as {}, but the reference locale \"{}\"'s is {}",
1654                                            ref_entry.code,
1655                                            locale_kind.as_str(),
1656                                            reference.tag,
1657                                            ref_kind.as_str(),
1658                                        ),
1659                                    ),
1660                                );
1661                            }
1662                        }
1663                    }
1664                }
1665            }
1666            _ => {
1667                let (_, first_ref_span) = reference_sites[0];
1668                for &(i, span) in &reference_sites[1..] {
1669                    errors.push_for(
1670                        Some(&parsed[i].identity_path()),
1671                        CompileError::new(
1672                            "bynk.messages.multiple_reference",
1673                            span,
1674                            "a message bundle must have exactly one `@reference` block; found more than one",
1675                        )
1676                        .with_label(first_ref_span, "first `@reference` here"),
1677                    );
1678                }
1679            }
1680        }
1681        // Locale-negotiation-slice-2 follow-up (#886): the synthetic `render`
1682        // this commons gets (`synthetic_render_fn`, symbols.rs) names
1683        // `LocaleTag`/`Message` by `TypeRef::Named` — real, resolved
1684        // references, not bypassed — so both `bynk.locale` (for `render`
1685        // itself) and `bynk.locale.types` (for the types its signature
1686        // names) must be `uses`d. Kept as one diagnostic, not two: a message
1687        // bundle always needs both together, so splitting the code would
1688        // just be two author-facing fixes for one underlying requirement.
1689        let targets = unit_uses.get(name);
1690        let has_locale_uses =
1691            targets.is_some_and(|targets| targets.iter().any(|t| t == firstparty::LOCALE_UNIT));
1692        let has_locale_types_uses = targets
1693            .is_some_and(|targets| targets.iter().any(|t| t == firstparty::LOCALE_TYPES_UNIT));
1694        if !has_locale_uses || !has_locale_types_uses {
1695            let missing = match (has_locale_uses, has_locale_types_uses) {
1696                (false, false) => "`bynk.locale` and `bynk.locale.types`",
1697                (false, true) => "`bynk.locale`",
1698                (true, false) => "`bynk.locale.types`",
1699                (true, true) => unreachable!("at least one of the two is missing here"),
1700            };
1701            errors.push_for(
1702                Some(&parsed[first_i].identity_path()),
1703                CompileError::new(
1704                    "bynk.messages.missing_locale_dependency",
1705                    first_span,
1706                    format!("a commons declaring `messages` must also `uses` {missing}"),
1707                ),
1708            );
1709        }
1710    }
1711}
1712
1713/// Locale capability track, slice 2 (#882): a context whose direct `uses`
1714/// reaches two or more message-bundle commons has no principled single
1715/// answer for what `Locale.current()` should negotiate against — but this
1716/// is only worth diagnosing when the context actually `consumes bynk {
1717/// Locale }` at all; a context with 2+ bundles that never touches `Locale`
1718/// has nothing ambiguous to resolve.
1719///
1720/// P5.0 (`design/tracks/semantics-in-the-checker.md` §6): relocated verbatim
1721/// from `bynk-emit/src/project/validate.rs`'s `check_locale_bundle_ambiguity`
1722/// — category 3 of `analysis.rs`'s own seven-category accounting, the second
1723/// of this slice's two live editor-diagnostics regressions.
1724pub fn phase_locale_bundle_ambiguity(
1725    parsed: &[ParsedFile],
1726    groups: &BTreeMap<String, Vec<usize>>,
1727    kinds: &BTreeMap<String, UnitKind>,
1728    unit_uses: &HashMap<String, Vec<String>>,
1729    unit_flattened: &HashMap<String, HashMap<String, String>>,
1730    errors: &mut ErrorSink,
1731) {
1732    for (name, indices) in groups {
1733        if kinds.get(name) != Some(&UnitKind::Context) {
1734            continue;
1735        }
1736        let ContextMessageBundle::Many(bundles) =
1737            detect_context_message_bundle(name, unit_uses, groups, kinds, parsed)
1738        else {
1739            continue;
1740        };
1741        let consumes_locale = unit_flattened
1742            .get(name)
1743            .and_then(|m| m.get("Locale"))
1744            .is_some_and(|owner| owner == firstparty::BYNK_UNIT);
1745        if !consumes_locale {
1746            continue;
1747        }
1748        for &i in indices {
1749            for c in parsed[i].consumes() {
1750                if c.target.joined() != firstparty::BYNK_UNIT {
1751                    continue;
1752                }
1753                let Some(locale_ident) = c.selected.iter().flatten().find(|id| id.name == "Locale")
1754                else {
1755                    continue;
1756                };
1757                let mut err = CompileError::new(
1758                    "bynk.locale.multiple_message_bundles",
1759                    locale_ident.span,
1760                    format!(
1761                        "context `{name}` uses {} message bundles ({}) — `Locale.current()` has no single bundle to negotiate against",
1762                        bundles.len(),
1763                        bundles.join(", "),
1764                    ),
1765                );
1766                for &j in indices {
1767                    for u in parsed[j].uses() {
1768                        if bundles.contains(&u.target.joined()) {
1769                            err = err
1770                                .with_label(u.span, format!("`{}` used here", u.target.joined()));
1771                        }
1772                    }
1773                }
1774                errors.push_for(Some(&parsed[i].identity_path()), err);
1775            }
1776        }
1777    }
1778}
1779
1780/// A message bundle entry's ICU template, syntax-checked at parse time
1781/// against the ICU MessageFormat grammar `icu.rs` implements. Relocated
1782/// alongside `phase_messages_bundles` (P5.0) — its only caller.
1783fn check_entry_icu_syntax(entry: &MessageEntry, file: Option<&Path>, errors: &mut ErrorSink) {
1784    for (inner_offset, inner) in icu::icu_dispatch_placeholders(&entry.template) {
1785        if let Err(e) = icu::parse_icu_placeholder(inner) {
1786            let decoded_start = inner_offset + e.offset;
1787            let decoded_span = Span::new(decoded_start, decoded_start + e.len);
1788            let raw_span = decoded_span.offset(entry.template_span.start + 1);
1789            errors.push_for(
1790                file,
1791                CompileError::new(
1792                    "bynk.messages.malformed_icu_syntax",
1793                    raw_span,
1794                    e.kind.message(),
1795                ),
1796            );
1797        }
1798    }
1799}
1800
1801/// Events track, slice 0 (spine #936): a `from Events(E)` subscription must
1802/// name a real, declared event — owned either by this context or by a
1803/// context it `consumes` (mirroring `discover_event_subscribers`'s own
1804/// ownership resolution, `project.rs`, which silently drops an unresolvable
1805/// subscription rather than diagnosing it). Runs at the project-wide phase
1806/// (needs `unit_tables` + `unit_consumes` together, unlike the local, per-
1807/// context `check_service_protocols`), alongside the other cross-unit checks
1808/// that need the same two maps.
1809///
1810/// P5.1 (`design/tracks/semantics-in-the-checker.md` §6): relocated verbatim
1811/// from `bynk-emit/src/project/validate.rs`'s `check_event_subscriptions` —
1812/// category 4 of `analysis.rs`'s own seven-category accounting, the third
1813/// live editor-diagnostics regression this track closes.
1814pub fn phase_event_subscriptions(
1815    parsed: &[ParsedFile],
1816    groups: &BTreeMap<String, Vec<usize>>,
1817    kinds: &BTreeMap<String, UnitKind>,
1818    unit_tables: &HashMap<String, UnitTable>,
1819    unit_consumes: &HashMap<String, Vec<String>>,
1820    unit_uses: &HashMap<String, Vec<String>>,
1821    errors: &mut ErrorSink,
1822) {
1823    for (name, indices) in groups {
1824        if kinds.get(name) != Some(&UnitKind::Context) {
1825            continue;
1826        }
1827        let consumed = unit_consumes.get(name).cloned().unwrap_or_default();
1828        for &i in indices {
1829            for item in parsed[i].items() {
1830                let CommonsItem::Service(s) = item else {
1831                    continue;
1832                };
1833                let ServiceProtocol::Events {
1834                    event_type,
1835                    pattern,
1836                    schema_dispatch,
1837                } = &s.protocol
1838                else {
1839                    continue;
1840                };
1841                // Events track, slice 4 (spine #936): `via schema(N)`'s
1842                // legality needs nothing about the subscribed event itself
1843                // (unlike the payload pattern below), so it's checked
1844                // independently of whether the subscription even resolves.
1845                if let Some(dispatch) = schema_dispatch {
1846                    check_schema_dispatch(dispatch, &parsed[i].identity_path(), errors);
1847                }
1848                let TypeRef::Named(id) = event_type else {
1849                    continue;
1850                };
1851                let owner_locally = unit_tables
1852                    .get(name)
1853                    .filter(|t| t.events.contains_key(&id.name))
1854                    .map(|_| name.clone());
1855                let owner_consumed = consumed.iter().find(|c| {
1856                    unit_tables
1857                        .get(*c)
1858                        .is_some_and(|t| t.events.contains_key(&id.name))
1859                });
1860                let owner = owner_locally
1861                    .as_deref()
1862                    .or(owner_consumed.map(String::as_str));
1863                let Some(owner) = owner else {
1864                    errors.push_for(
1865                        Some(&parsed[i].identity_path()),
1866                        CompileError::new(
1867                            "bynk.event.unknown_subscription",
1868                            id.span,
1869                            format!(
1870                                "`{}` is not a declared event in this context or any consumed context",
1871                                id.name
1872                            ),
1873                        )
1874                        .with_note(
1875                            "check the spelling, or add `consumes <context>` for the context whose `event` this names — an unresolvable subscription never receives anything, silently",
1876                        ),
1877                    );
1878                    continue;
1879                };
1880                // Events track, slice 1 (spine #936): once the event itself
1881                // resolves, check the subscription pattern's fields against
1882                // its declared record shape. No pattern is the pattern-less
1883                // form (slice 0) and needs none of this.
1884                let Some(pattern) = pattern else {
1885                    continue;
1886                };
1887                let Some(event_decl) = unit_tables.get(owner).and_then(|t| t.events.get(&id.name))
1888                else {
1889                    continue;
1890                };
1891                check_event_pattern(
1892                    pattern,
1893                    event_decl,
1894                    owner,
1895                    unit_tables,
1896                    unit_uses,
1897                    &parsed[i].identity_path(),
1898                    errors,
1899                );
1900            }
1901        }
1902    }
1903}
1904
1905/// Events track, slice 1 (spine #936): resolve a subscription pattern's
1906/// fields/values against the owning event's declared record shape. `owner`
1907/// is the context that declares `event_decl` (may differ from the
1908/// subscribing context, reached via `consumes`) — a field's own type (e.g. a
1909/// discriminator sum like `Region`) resolves against the *owner's* types
1910/// (locally declared, or pulled in via the owner's own `uses <commons>`),
1911/// mirroring how the field's type is resolved everywhere else the event's
1912/// record shape is used.
1913fn check_event_pattern(
1914    pattern: &EventPattern,
1915    event_decl: &EventDecl,
1916    owner: &str,
1917    unit_tables: &HashMap<String, UnitTable>,
1918    unit_uses: &HashMap<String, Vec<String>>,
1919    identity_path: &std::path::Path,
1920    errors: &mut ErrorSink,
1921) {
1922    let mut seen: HashSet<String> = HashSet::new();
1923    for field in &pattern.fields {
1924        if !seen.insert(field.name.name.clone()) {
1925            errors.push_for(
1926                Some(identity_path),
1927                CompileError::new(
1928                    "bynk.event.pattern_duplicate_field",
1929                    field.name.span,
1930                    format!(
1931                        "field `{}` is matched more than once in this subscription pattern",
1932                        field.name.name
1933                    ),
1934                ),
1935            );
1936            continue;
1937        }
1938        let Some(record_field) = event_decl
1939            .body
1940            .fields
1941            .iter()
1942            .find(|f| f.name.name == field.name.name)
1943        else {
1944            let known: Vec<&str> = event_decl
1945                .body
1946                .fields
1947                .iter()
1948                .map(|f| f.name.name.as_str())
1949                .collect();
1950            errors.push_for(
1951                Some(identity_path),
1952                CompileError::new(
1953                    "bynk.event.pattern_unknown_field",
1954                    field.name.span,
1955                    format!(
1956                        "`{}` has no field named `{}`",
1957                        event_decl.name.name, field.name.name
1958                    ),
1959                )
1960                .with_note(format!(
1961                    "declared fields: {}",
1962                    if known.is_empty() {
1963                        "(none)".to_string()
1964                    } else {
1965                        known.join(", ")
1966                    }
1967                )),
1968            );
1969            continue;
1970        };
1971        check_event_pattern_value(
1972            &field.value,
1973            record_field,
1974            owner,
1975            unit_tables,
1976            unit_uses,
1977            identity_path,
1978            errors,
1979        );
1980    }
1981}
1982
1983/// Events track, slice 4 (spine #936): `via schema(N)`'s `N` must be a
1984/// positive `Int` literal — the identical rule `@schema(N)` already
1985/// enforces (`bynk.event.bad_schema_version`), reused under its own code
1986/// since the two are unrelated syntax positions (an annotation on the
1987/// event's own declaration vs. a clause on a subscriber's header).
1988fn check_schema_dispatch(
1989    dispatch: &SchemaDispatch,
1990    identity_path: &std::path::Path,
1991    errors: &mut ErrorSink,
1992) {
1993    let SchemaVersionPattern::Literal(n) = &dispatch.pattern;
1994    if *n <= 0 {
1995        errors.push_for(
1996            Some(identity_path),
1997            CompileError::new(
1998                "bynk.event.bad_schema_dispatch",
1999                dispatch.span,
2000                "`via schema(...)`'s argument must be a positive `Int` literal",
2001            ),
2002        );
2003    }
2004}
2005
2006/// Resolve one pattern field's matched value against that field's declared
2007/// type — a literal must match the field's base type; a variant must name a
2008/// nullary member of the field's sum type.
2009fn check_event_pattern_value(
2010    value: &EventPatternValue,
2011    record_field: &RecordField,
2012    owner: &str,
2013    unit_tables: &HashMap<String, UnitTable>,
2014    unit_uses: &HashMap<String, Vec<String>>,
2015    identity_path: &std::path::Path,
2016    errors: &mut ErrorSink,
2017) {
2018    match value {
2019        EventPatternValue::Literal { value: lit, span } => {
2020            // A base type (`Int`/`String`/`Bool`/…) is its own `TypeRef`
2021            // variant, not `TypeRef::Named` — only a *user*-declared type
2022            // (including a refined/opaque type built on a base) goes through
2023            // `resolve_type_decl`. An earlier version of this match only
2024            // handled the `Named` case, so a plain `orderId: String` field
2025            // (the common case) fell through to "not a literal-kind type",
2026            // caught by `events_workers_wiring.rs`'s patterned fixture.
2027            let base = match &record_field.type_ref {
2028                TypeRef::Base(b, _) => Some(*b),
2029                TypeRef::Named(field_type_name) => {
2030                    resolve_type_decl(unit_tables, unit_uses, owner, &field_type_name.name)
2031                        .and_then(|d| match &d.body {
2032                            TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. } => {
2033                                Some(*base)
2034                            }
2035                            _ => None,
2036                        })
2037                }
2038                _ => None,
2039            };
2040            let Some(base) = base else {
2041                errors.push_for(
2042                    Some(identity_path),
2043                    CompileError::new(
2044                        "bynk.event.pattern_type_mismatch",
2045                        *span,
2046                        format!(
2047                            "field `{}` is not a literal-kind type — a literal pattern value cannot match it",
2048                            record_field.name.name
2049                        ),
2050                    ),
2051                );
2052                return;
2053            };
2054            let kind_matches = matches!(
2055                (lit, base),
2056                (LiteralValue::Int(_), BaseType::Int)
2057                    | (LiteralValue::Str(_), BaseType::String)
2058                    | (LiteralValue::Bool(_), BaseType::Bool)
2059            );
2060            if !kind_matches {
2061                errors.push_for(
2062                    Some(identity_path),
2063                    CompileError::new(
2064                        "bynk.event.pattern_type_mismatch",
2065                        *span,
2066                        format!(
2067                            "this literal does not match the type of field `{}` (`{}`)",
2068                            record_field.name.name,
2069                            type_ref_to_display(&record_field.type_ref)
2070                        ),
2071                    ),
2072                );
2073            }
2074        }
2075        EventPatternValue::Variant {
2076            type_name,
2077            variant,
2078            span,
2079        } => {
2080            let TypeRef::Named(field_type_name) = &record_field.type_ref else {
2081                errors.push_for(
2082                    Some(identity_path),
2083                    CompileError::new(
2084                        "bynk.event.pattern_type_mismatch",
2085                        *span,
2086                        format!(
2087                            "field `{}` is not a sum type — a variant pattern value cannot match it",
2088                            record_field.name.name
2089                        ),
2090                    ),
2091                );
2092                return;
2093            };
2094            if let Some(qualifier) = type_name
2095                && qualifier.name != field_type_name.name
2096            {
2097                errors.push_for(
2098                    Some(identity_path),
2099                    CompileError::new(
2100                        "bynk.event.pattern_type_mismatch",
2101                        qualifier.span,
2102                        format!(
2103                            "field `{}` has type `{}`, not `{}`",
2104                            record_field.name.name, field_type_name.name, qualifier.name
2105                        ),
2106                    ),
2107                );
2108                return;
2109            }
2110            let Some(decl) =
2111                resolve_type_decl(unit_tables, unit_uses, owner, &field_type_name.name)
2112            else {
2113                // The field's own type failed to resolve — a different,
2114                // pre-existing check (ordinary type-reference resolution)
2115                // already reports this; don't double-report it here.
2116                return;
2117            };
2118            let TypeBody::Sum(sum) = &decl.body else {
2119                errors.push_for(
2120                    Some(identity_path),
2121                    CompileError::new(
2122                        "bynk.event.pattern_type_mismatch",
2123                        *span,
2124                        format!(
2125                            "field `{}` has type `{}`, which is not a sum type",
2126                            record_field.name.name, field_type_name.name
2127                        ),
2128                    ),
2129                );
2130                return;
2131            };
2132            let Some(member) = sum.variants.iter().find(|v| v.name.name == variant.name) else {
2133                errors.push_for(
2134                    Some(identity_path),
2135                    CompileError::new(
2136                        "bynk.event.pattern_unknown_variant",
2137                        variant.span,
2138                        format!(
2139                            "`{}` has no variant named `{}`",
2140                            field_type_name.name, variant.name
2141                        ),
2142                    ),
2143                );
2144                return;
2145            };
2146            if !member.payload.is_empty() {
2147                errors.push_for(
2148                    Some(identity_path),
2149                    CompileError::new(
2150                        "bynk.event.pattern_variant_payload",
2151                        variant.span,
2152                        format!(
2153                            "`{}.{}` carries a payload — only a nullary variant may be matched here, since testing the tag alone would silently ignore the payload",
2154                            field_type_name.name, variant.name
2155                        ),
2156                    ),
2157                );
2158            }
2159        }
2160    }
2161}
2162
2163/// Resolve a named type as `owner` sees it: the context's own `types` first,
2164/// then any commons unit it `uses`. Events track slice 1 (spine #936) needs
2165/// this because a pattern field's type (e.g. a discriminator sum) may be
2166/// declared in a commons the event's owning context pulls in with `uses`,
2167/// rather than in the context itself.
2168fn resolve_type_decl<'a>(
2169    unit_tables: &'a HashMap<String, UnitTable>,
2170    unit_uses: &HashMap<String, Vec<String>>,
2171    owner: &str,
2172    name: &str,
2173) -> Option<&'a Arc<TypeDecl>> {
2174    if let Some(t) = unit_tables.get(owner).and_then(|t| t.types.get(name)) {
2175        return Some(t);
2176    }
2177    for used in unit_uses.get(owner).into_iter().flatten() {
2178        if let Some(t) = unit_tables.get(used).and_then(|t| t.types.get(name)) {
2179            return Some(t);
2180        }
2181    }
2182    None
2183}
2184
2185/// Phase 6b: validate each context/adapter's `exports opaque/transparent { … }`
2186/// clauses — every name must be a locally-declared type, with no duplicates
2187/// within a clause or conflicting visibilities across clauses. Returns unit →
2188/// (type → visibility); diagnostics go into `errors` and export references into
2189/// `refs`.
2190pub fn phase_validate_type_exports(
2191    groups: &BTreeMap<String, Vec<usize>>,
2192    kinds: &BTreeMap<String, UnitKind>,
2193    parsed: &[ParsedFile],
2194    unit_tables: &HashMap<String, UnitTable>,
2195    errors: &mut ErrorSink,
2196    refs: &mut RefSink,
2197) -> HashMap<String, HashMap<String, Visibility>> {
2198    let mut exports_visibility: HashMap<String, HashMap<String, Visibility>> = HashMap::new();
2199    for (name, indices) in groups {
2200        let kind = *kinds.get(name).unwrap();
2201        if kind != UnitKind::Context && kind != UnitKind::Adapter {
2202            // Commons may not have exports clauses (parsed grammar prevents it
2203            // at the parser level), but in case any sneak in, skip.
2204            continue;
2205        }
2206        let local = unit_tables.get(name).unwrap();
2207        let mut seen: HashMap<String, (Visibility, Span)> = HashMap::new();
2208        for &i in indices {
2209            refs.enter_file(&parsed[i].identity_path(), name, parsed[i].is_synthetic());
2210            for clause in parsed[i].exports() {
2211                // v0.15: `exports capability { ... }` clauses are validated
2212                // separately (§4.1); 6b handles only type exports.
2213                let ExportKind::Type(clause_vis) = clause.kind else {
2214                    continue;
2215                };
2216                let mut within: HashMap<String, Span> = HashMap::new();
2217                for n in &clause.names {
2218                    if let Some(prev) = within.get(&n.name) {
2219                        errors.push_for(
2220                            Some(&parsed[i].identity_path()),
2221                            CompileError::new(
2222                                "bynk.exports.duplicate_in_clause",
2223                                n.span,
2224                                format!(
2225                                    "type `{}` appears more than once in this exports clause",
2226                                    n.name
2227                                ),
2228                            )
2229                            .with_label(*prev, "previously listed here"),
2230                        );
2231                        continue;
2232                    }
2233                    within.insert(n.name.clone(), n.span);
2234
2235                    if !local.types.contains_key(&n.name) {
2236                        errors.push_for(Some(&parsed[i].identity_path()),
2237                            CompileError::new(
2238                                "bynk.exports.undeclared_type",
2239                                n.span,
2240                                format!(
2241                                    "exports clause references `{}`, which is not a type declared in context `{}`",
2242                                    n.name, name
2243                                ),
2244                            )
2245                            .with_note(
2246                                "only types declared in the same context can appear in `exports` clauses",
2247                            ),
2248                        );
2249                        continue;
2250                    }
2251                    // v0.25: `exports opaque/transparent { T }` names the type.
2252                    refs.record(n.span, SymbolKind::Type, &n.name);
2253
2254                    if let Some((prev_vis, prev_span)) = seen.get(&n.name) {
2255                        if *prev_vis == clause_vis {
2256                            errors.push_for(
2257                                Some(&parsed[i].identity_path()),
2258                                CompileError::new(
2259                                    "bynk.exports.duplicate_export",
2260                                    n.span,
2261                                    format!("type `{}` is exported more than once", n.name),
2262                                )
2263                                .with_label(*prev_span, "previously exported here"),
2264                            );
2265                        } else {
2266                            errors.push_for(Some(&parsed[i].identity_path()),
2267                                CompileError::new(
2268                                    "bynk.exports.conflicting_visibility",
2269                                    n.span,
2270                                    format!(
2271                                        "type `{}` is exported with conflicting visibilities — pick `opaque` or `transparent`",
2272                                        n.name,
2273                                    ),
2274                                )
2275                                .with_label(*prev_span, "previously exported here"),
2276                            );
2277                        }
2278                        continue;
2279                    }
2280                    seen.insert(n.name.clone(), (clause_vis, n.span));
2281                }
2282            }
2283        }
2284        let mut visibility_map: HashMap<String, Visibility> = HashMap::new();
2285        for (n, (v, _)) in seen {
2286            visibility_map.insert(n, v);
2287        }
2288        exports_visibility.insert(name.clone(), visibility_map);
2289    }
2290    exports_visibility
2291}
2292
2293/// Phase 6b': validate each context/adapter's `exports capability { … }` clauses
2294/// (v0.15 §4.1) — every name must be a capability the unit declares *and*
2295/// provides, with no duplicate exports. Diagnostics go into `errors` and export
2296/// references into `refs`.
2297pub fn phase_validate_capability_exports(
2298    groups: &BTreeMap<String, Vec<usize>>,
2299    kinds: &BTreeMap<String, UnitKind>,
2300    parsed: &[ParsedFile],
2301    unit_tables: &HashMap<String, UnitTable>,
2302    errors: &mut ErrorSink,
2303    refs: &mut RefSink,
2304) {
2305    for (name, indices) in groups {
2306        if kinds.get(name) != Some(&UnitKind::Context)
2307            && kinds.get(name) != Some(&UnitKind::Adapter)
2308        {
2309            continue;
2310        }
2311        let local = unit_tables.get(name).unwrap();
2312        let mut seen: HashMap<String, Span> = HashMap::new();
2313        for &i in indices {
2314            refs.enter_file(&parsed[i].identity_path(), name, parsed[i].is_synthetic());
2315            for clause in parsed[i].exports() {
2316                if !matches!(clause.kind, ExportKind::Capability) {
2317                    continue;
2318                }
2319                for n in &clause.names {
2320                    if let Some(prev) = seen.get(&n.name) {
2321                        errors.push_for(
2322                            Some(&parsed[i].identity_path()),
2323                            CompileError::new(
2324                                "bynk.exports.duplicate_export",
2325                                n.span,
2326                                format!("capability `{}` is exported more than once", n.name),
2327                            )
2328                            .with_label(*prev, "previously exported here"),
2329                        );
2330                        continue;
2331                    }
2332                    seen.insert(n.name.clone(), n.span);
2333                    if local.capabilities.contains_key(&n.name) {
2334                        // v0.25: `exports capability { Cap }` names the
2335                        // capability.
2336                        refs.record(n.span, SymbolKind::Capability, &n.name);
2337                    }
2338                    if !local.capabilities.contains_key(&n.name) {
2339                        errors.push_for(Some(&parsed[i].identity_path()),
2340                            CompileError::new(
2341                                "bynk.exports.undeclared_capability",
2342                                n.span,
2343                                format!(
2344                                    "`exports capability` references `{}`, which is not a capability declared in context `{}`",
2345                                    n.name, name
2346                                ),
2347                            )
2348                            .with_note(
2349                                "only capabilities declared in the same context can appear in `exports capability` clauses",
2350                            ),
2351                        );
2352                        continue;
2353                    }
2354                    if !local.providers.contains_key(&n.name) {
2355                        errors.push_for(Some(&parsed[i].identity_path()),
2356                            CompileError::new(
2357                                "bynk.exports.capability_not_provided",
2358                                n.span,
2359                                format!(
2360                                    "exported capability `{}` has no provider in context `{}` — a consumer cannot instantiate it",
2361                                    n.name, name
2362                                ),
2363                            )
2364                            .with_note(
2365                                "add a `provides {n} = …` declaration so the capability can be wired into consumers",
2366                            ),
2367                        );
2368                    }
2369                }
2370            }
2371        }
2372    }
2373}
2374
2375/// Phase 6c: validate that every (non-external) provider matches its capability
2376/// exactly — each capability op has a provider op, and every provider op has a
2377/// matching capability op with the same parameter and return types. Diagnostics
2378/// go into `errors`.
2379pub fn phase_validate_providers(
2380    unit_tables: &HashMap<String, UnitTable>,
2381    // #696: the merged `UnitTable` has flattened a unit's files away, so provider
2382    // diagnostics need the group's files to recover which one declares each
2383    // provider and attribute the diagnostic to it.
2384    groups: &BTreeMap<String, Vec<usize>>,
2385    parsed: &[ParsedFile],
2386    errors: &mut ErrorSink,
2387    tys: &Arc<Types>,
2388) {
2389    for (name, table) in unit_tables {
2390        // Map each provided capability to the project-relative path of the file
2391        // that declares its provider — every diagnostic below carries a span into
2392        // that file.
2393        let provider_files: HashMap<&str, PathBuf> = groups
2394            .get(name)
2395            .map(|indices| {
2396                indices
2397                    .iter()
2398                    .flat_map(|&i| {
2399                        parsed[i].items().iter().filter_map(move |item| match item {
2400                            CommonsItem::Provider(p) => {
2401                                Some((p.capability.name.as_str(), parsed[i].identity_path()))
2402                            }
2403                            _ => None,
2404                        })
2405                    })
2406                    .collect()
2407            })
2408            .unwrap_or_default();
2409        for (cap_name, provider) in &table.providers {
2410            let provider_file = provider_files.get(cap_name.as_str()).map(|p| p.as_path());
2411            // v0.17: an external provider has no Bynk body to match against the
2412            // capability — its implementation is the binding, checked by `tsc`.
2413            if provider.external {
2414                continue;
2415            }
2416            let Some(cap) = table.capabilities.get(cap_name) else {
2417                errors.push_for(provider_file,
2418                    CompileError::new(
2419                        "bynk.provider.unknown_capability",
2420                        provider.capability.span,
2421                        format!(
2422                            "provider targets unknown capability `{}` — declare the capability in the same context",
2423                            cap_name
2424                        ),
2425                    ),
2426                );
2427                continue;
2428            };
2429            // #926 (Decision E): a capability op with its own type parameter(s)
2430            // cannot be implemented by a Bynk-bodied provider — the body would
2431            // need `T` rigid through the handler-body checker for a body that
2432            // can only ever return `None` or echo a `T`-typed parameter.
2433            // External providers (checked above) are exempt: TypeScript
2434            // natively supports a generic interface method, so a hand-authored
2435            // binding class implements it directly.
2436            for cap_op in &cap.ops {
2437                if !cap_op.type_params.is_empty() {
2438                    errors.push_for(
2439                        provider_file,
2440                        CompileError::new(
2441                            "bynk.provider.generic_op_requires_external",
2442                            provider.span,
2443                            format!(
2444                                "provider `{}` for capability `{}` has a Bynk body, but operation `{}` declares its own type parameter(s) (`[{}]`) — a generic capability operation requires an external (bodiless) provider",
2445                                provider.provider_name.name,
2446                                cap_name,
2447                                cap_op.name.name,
2448                                cap_op
2449                                    .type_params
2450                                    .iter()
2451                                    .map(|p| p.name.name.as_str())
2452                                    .collect::<Vec<_>>()
2453                                    .join(", "),
2454                            ),
2455                        )
2456                        .with_note(
2457                            "write `provides Cap = Name` with no `{ … }` block, and supply the implementation as a hand-authored class in the adapter's binding file",
2458                        ),
2459                    );
2460                }
2461            }
2462            // 1) Every capability op has a provider op.
2463            for cap_op in &cap.ops {
2464                if !provider.ops.iter().any(|o| o.name.name == cap_op.name.name) {
2465                    errors.push_for(
2466                        provider_file,
2467                        CompileError::new(
2468                            "bynk.provider.missing_operation",
2469                            provider.span,
2470                            format!(
2471                                "provider `{}` for capability `{}` is missing operation `{}`",
2472                                provider.provider_name.name, cap_name, cap_op.name.name
2473                            ),
2474                        ),
2475                    );
2476                }
2477            }
2478            // 2) Every provider op corresponds to a capability op with the
2479            //    same signature (param types and return type).
2480            for prov_op in &provider.ops {
2481                let Some(cap_op) = cap.ops.iter().find(|o| o.name.name == prov_op.name.name) else {
2482                    errors.push_for(provider_file, CompileError::new(
2483                        "bynk.provider.extra_operation",
2484                        prov_op.span,
2485                        format!(
2486                            "provider operation `{}.{}` does not match any operation in capability `{}`",
2487                            provider.provider_name.name, prov_op.name.name, cap_name
2488                        ),
2489                    ));
2490                    continue;
2491                };
2492                if cap_op.params.len() != prov_op.params.len() {
2493                    errors.push_for(provider_file, CompileError::new(
2494                        "bynk.provider.signature_mismatch",
2495                        prov_op.span,
2496                        format!(
2497                            "provider operation `{}.{}` has {} parameter(s), but capability operation expects {}",
2498                            provider.provider_name.name,
2499                            prov_op.name.name,
2500                            prov_op.params.len(),
2501                            cap_op.params.len()
2502                        ),
2503                    ));
2504                    continue;
2505                }
2506                // Resolved-`Ty` equality, not surface-syntax comparison: two
2507                // signatures that spell a type differently (an alias, or a
2508                // generic application written out) but resolve to the same
2509                // `Ty` must not be flagged as a mismatch, and — the bug this
2510                // replaces — a `TypeRef` shape `type_refs_match` didn't cover
2511                // (List/Map/Query/Stream/Connection/…) must not be silently
2512                // treated as *matching* just because it fell through to
2513                // `_ => false` on both sides of an `!`. A Bynk-bodied
2514                // provider op has no type params of its own (checked above),
2515                // so its params/return type resolve with no vars in scope.
2516                let cap_info = build_capability_op_info(cap_op, &table.types, tys);
2517                let no_vars = HashSet::new();
2518                let prov_params: Vec<TyId> = prov_op
2519                    .params
2520                    .iter()
2521                    .map(|p| {
2522                        checker::resolve_type_ref_in(&p.type_ref, &table.types, &no_vars, tys)
2523                            .unwrap_or(tys.intern(Ty::Unit))
2524                    })
2525                    .collect();
2526                let prov_return_ty =
2527                    checker::resolve_type_ref_in(&prov_op.return_type, &table.types, &no_vars, tys)
2528                        .unwrap_or(tys.intern(Ty::Unit));
2529                for (i, (cap_ty, (prov_p, prov_ty))) in cap_info
2530                    .params
2531                    .iter()
2532                    .zip(prov_op.params.iter().zip(prov_params.iter()))
2533                    .enumerate()
2534                {
2535                    if cap_ty != prov_ty {
2536                        errors.push_for(provider_file, CompileError::new(
2537                            "bynk.provider.signature_mismatch",
2538                            prov_p.span,
2539                            format!(
2540                                "provider operation `{}.{}` parameter {} has type `{}`, but capability declares `{}`",
2541                                provider.provider_name.name,
2542                                prov_op.name.name,
2543                                i + 1,
2544                                ts_type_ref_display(&prov_p.type_ref),
2545                                ts_type_ref_display(&cap_op.params[i].type_ref)
2546                            ),
2547                        ));
2548                    }
2549                }
2550                if cap_info.return_ty != prov_return_ty {
2551                    errors.push_for(provider_file, CompileError::new(
2552                        "bynk.provider.signature_mismatch",
2553                        prov_op.return_type.span(),
2554                        format!(
2555                            "provider operation `{}.{}` returns `{}`, but capability declares `{}`",
2556                            provider.provider_name.name,
2557                            prov_op.name.name,
2558                            ts_type_ref_display(&prov_op.return_type),
2559                            ts_type_ref_display(&cap_op.return_type)
2560                        ),
2561                    ));
2562                }
2563            }
2564        }
2565    }
2566}
2567
2568/// v0.19: the lock violation a deployment unit's native-platform set implies
2569/// under the selected `--platform`, if any. Pure — unit-tested below with
2570/// synthetic sets (the conflict arm is not yet reachable end-to-end while
2571/// only one platform ships native capabilities).
2572///
2573/// P5.3 (`design/tracks/semantics-in-the-checker.md` §6): relocated
2574/// verbatim from `bynk-emit/src/project/validate.rs`, alongside
2575/// [`phase_platform_lock`].
2576fn lock_violation(
2577    native: &BTreeMap<Platform, String>,
2578    selected: Platform,
2579) -> Option<LockViolation> {
2580    let mut platforms = native.iter();
2581    let (first, first_unit) = platforms.next()?;
2582    if let Some((second, second_unit)) = platforms.next() {
2583        return Some(LockViolation::Conflict {
2584            a: (*first, first_unit.clone()),
2585            b: (*second, second_unit.clone()),
2586        });
2587    }
2588    if *first != selected {
2589        return Some(LockViolation::Required {
2590            needed: *first,
2591            unit: first_unit.clone(),
2592        });
2593    }
2594    None
2595}
2596
2597/// A platform-lock violation (v0.19, `bynk.target.*`).
2598#[derive(Debug, PartialEq, Eq)]
2599enum LockViolation {
2600    /// The deployment unit needs `needed` but another platform is selected.
2601    Required { needed: Platform, unit: String },
2602    /// The deployment unit's closure spans two mutually-exclusive platforms.
2603    Conflict {
2604        a: (Platform, String),
2605        b: (Platform, String),
2606    },
2607}
2608
2609/// v0.15's cross-context capability resolution, relocated alongside
2610/// [`phase_platform_lock`] (P5.3): resolve a `given`/handler capability
2611/// prefix (`ctx.Cap`) against a context's own `consumes`/alias tables. Pure —
2612/// no codegen, no `bynk-emit` dependency of its own — so unlike
2613/// `collect_given_closure` this one **is** shared rather than duplicated:
2614/// `bynk-emit/src/project.rs`'s own copy of this function (and of
2615/// [`handler_cross_caps`]) was deleted in review (#1133) and every one of its
2616/// call sites repointed here — `bynk-emit` already depends on `bynk-check`,
2617/// so there was no dependency direction to route around, and keeping two
2618/// copies only bought two things that could drift out of sync for no reason.
2619pub fn resolve_consume_prefix(
2620    prefix: &str,
2621    consumed: &[String],
2622    aliases: &HashMap<String, String>,
2623) -> Option<String> {
2624    if let Some(q) = aliases.get(prefix) {
2625        return Some(q.clone());
2626    }
2627    if consumed.iter().any(|c| c == prefix) {
2628        return Some(prefix.to_string());
2629    }
2630    None
2631}
2632
2633/// v0.15: the cross-context capabilities a context's **handlers** reference,
2634/// as `deps_key → consumed_context`. Shared with `bynk-emit`, not duplicated
2635/// — see [`resolve_consume_prefix`]'s doc.
2636pub fn handler_cross_caps(
2637    table: &UnitTable,
2638    consumed: &[String],
2639    aliases: &HashMap<String, String>,
2640    flattened: &HashMap<String, String>,
2641) -> BTreeMap<String, String> {
2642    let mut out = BTreeMap::new();
2643    let mut scan = |given: &[CapRef]| {
2644        for c in given {
2645            // Events track, slice 0 (spine #936): `Events.emit` is
2646            // intercepted entirely at the call site (release-at-commit
2647            // buffering) and never calls through a constructed provider —
2648            // there is no `EventsProvider` for compose to build, so the
2649            // first-party `Events` must never become a compose deps entry.
2650            if c.key() == "Events" && flattened.get(c.key()).map(String::as_str) == Some("bynk") {
2651                continue;
2652            }
2653            if let Some(p) = c.prefix() {
2654                if let Some(ctx) = resolve_consume_prefix(&p, consumed, aliases) {
2655                    out.entry(c.key().to_string()).or_insert(ctx);
2656                }
2657            } else if let Some(unit) = flattened.get(c.key()) {
2658                // v0.17: a bare flattened capability is provided by the unit it
2659                // was flattened from.
2660                out.entry(c.key().to_string())
2661                    .or_insert_with(|| unit.clone());
2662            }
2663        }
2664    };
2665    for s in table.services.values() {
2666        for h in &s.handlers {
2667            scan(&h.given);
2668        }
2669    }
2670    for a in table.agents.values() {
2671        for h in &a.handlers {
2672            scan(&h.given);
2673        }
2674    }
2675    out
2676}
2677
2678/// The units a provider capability's `given` closure transitively reaches,
2679/// recorded into `referenced_units`. P5.3: a pure resolution walk over the
2680/// same graph `bynk-emit`'s `instantiate_provider_expr` walks to build a
2681/// TypeScript instantiation expression — this one builds no TypeScript at
2682/// all, since `bynk-check` must never depend on `bynk-emit`'s codegen
2683/// (`bynk-emit` depends on `bynk-check`, never the reverse). The two walks
2684/// must keep resolving `given` targets identically (prefix → alias/consumes,
2685/// bare → flattened) or `phase_platform_lock`'s native-platform accounting
2686/// could drift from what a real build's compose actually instantiates; a
2687/// reviewer changing one should check the other.
2688fn collect_given_closure(
2689    provider_ctx: &str,
2690    cap: &str,
2691    unit_tables: &HashMap<String, UnitTable>,
2692    unit_consumes: &HashMap<String, Vec<String>>,
2693    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2694    unit_flattened: &HashMap<String, HashMap<String, String>>,
2695    referenced_units: &mut BTreeSet<String>,
2696) {
2697    referenced_units.insert(provider_ctx.to_string());
2698    let Some(provider) = unit_tables
2699        .get(provider_ctx)
2700        .and_then(|t| t.providers.get(cap))
2701    else {
2702        return;
2703    };
2704    if provider.given.is_empty() {
2705        return;
2706    }
2707    let consumed = unit_consumes.get(provider_ctx).cloned().unwrap_or_default();
2708    let aliases = unit_consumes_aliases
2709        .get(provider_ctx)
2710        .cloned()
2711        .unwrap_or_default();
2712    let flattened = unit_flattened
2713        .get(provider_ctx)
2714        .cloned()
2715        .unwrap_or_default();
2716    for g in &provider.given {
2717        let target_ctx = match g.prefix() {
2718            Some(p) => resolve_consume_prefix(&p, &consumed, &aliases)
2719                .unwrap_or_else(|| provider_ctx.to_string()),
2720            None => flattened
2721                .get(g.key())
2722                .cloned()
2723                .unwrap_or_else(|| provider_ctx.to_string()),
2724        };
2725        collect_given_closure(
2726            &target_ctx,
2727            g.key(),
2728            unit_tables,
2729            unit_consumes,
2730            unit_consumes_aliases,
2731            unit_flattened,
2732            referenced_units,
2733        );
2734    }
2735}
2736
2737/// v0.19 (decision 0017): the native platforms a context's **in-process
2738/// closure** commits it to: every unit whose provider its compose would
2739/// instantiate — local providers' `given` recursion plus the capabilities its
2740/// handlers reference — mapped through [`firstparty::platform_of`]. Each
2741/// platform carries an exemplar unit for the diagnostic message. Service
2742/// `consumes` edges (RPC under `workers`) do not contribute — only the
2743/// provider-instantiation walk, which is in-process by construction.
2744///
2745/// P5.3: relocated alongside [`phase_platform_lock`], reimplemented on
2746/// [`collect_given_closure`] rather than moved verbatim — see that
2747/// function's doc.
2748fn native_platforms_of_context(
2749    ctx: &str,
2750    table: &UnitTable,
2751    unit_tables: &HashMap<String, UnitTable>,
2752    unit_consumes: &HashMap<String, Vec<String>>,
2753    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2754    unit_flattened: &HashMap<String, HashMap<String, String>>,
2755) -> BTreeMap<Platform, String> {
2756    let mut referenced: BTreeSet<String> = BTreeSet::new();
2757    for cap in table.providers.keys() {
2758        collect_given_closure(
2759            ctx,
2760            cap,
2761            unit_tables,
2762            unit_consumes,
2763            unit_consumes_aliases,
2764            unit_flattened,
2765            &mut referenced,
2766        );
2767    }
2768    let consumed = unit_consumes.get(ctx).cloned().unwrap_or_default();
2769    let aliases = unit_consumes_aliases.get(ctx).cloned().unwrap_or_default();
2770    let flattened = unit_flattened.get(ctx).cloned().unwrap_or_default();
2771    for (key, cctx) in handler_cross_caps(table, &consumed, &aliases, &flattened) {
2772        collect_given_closure(
2773            &cctx,
2774            &key,
2775            unit_tables,
2776            unit_consumes,
2777            unit_consumes_aliases,
2778            unit_flattened,
2779            &mut referenced,
2780        );
2781    }
2782    let mut out = BTreeMap::new();
2783    for unit in referenced {
2784        if let Some(p) = firstparty::platform_of(&unit) {
2785            out.entry(p).or_insert(unit);
2786        }
2787    }
2788    out
2789}
2790
2791/// v0.19 (decisions 0017/0024): enforce the platform lock per deployment
2792/// unit — each context under `--target workers`, the whole program under
2793/// `bundle` (co-location shares the lock).
2794///
2795/// P5.3 (`design/tracks/semantics-in-the-checker.md` §6): relocated from
2796/// `bynk-emit/src/project/validate.rs`'s `check_platform_lock` — category 5
2797/// of `analysis.rs`'s own seven-category residual-gap accounting ("gap in
2798/// name only": `analyse_project` hardcodes `Platform::default()`
2799/// (Cloudflare) and `BuildTarget::Bundle`, and `bynk.cloudflare` is the only
2800/// platform-native unit that exists, so `lock_violation` can never fire on
2801/// that path regardless of where this function lives — see `analysis.rs`'s
2802/// own doc for why R3.5 still requires the move).
2803#[allow(clippy::too_many_arguments)]
2804pub fn phase_platform_lock(
2805    target: BuildTarget,
2806    selected: Platform,
2807    parsed: &[ParsedFile],
2808    groups: &BTreeMap<String, Vec<usize>>,
2809    kinds: &BTreeMap<String, UnitKind>,
2810    unit_tables: &HashMap<String, UnitTable>,
2811    unit_consumes: &HashMap<String, Vec<String>>,
2812    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2813    unit_flattened: &HashMap<String, HashMap<String, String>>,
2814    errors: &mut ErrorSink,
2815) {
2816    // In-browser track, slice 2: `browser` is a Bundle-only platform — a browser
2817    // cannot do the Workers wire-call model (Service Bindings, Durable Objects,
2818    // cross-context wire calls). Reject the combination up front, before the
2819    // per-unit native-platform lock below, which is moot for an invalid build.
2820    if selected == Platform::Browser && target == BuildTarget::Workers {
2821        errors.push_for(
2822            None,
2823            CompileError::new(
2824                "bynk.target.browser_bundle_only",
2825                Span::default(),
2826                "`--platform browser` builds only the in-process `Bundle` topology, but `--target workers` was selected; a browser cannot run the Workers wire-call model",
2827            )
2828            .with_note("build the browser target with `--target bundle` (the default)"),
2829        );
2830        return;
2831    }
2832    // v0.104 (real-time track slice 3b): the `from websocket` Workers mapping (the
2833    // Durable Object hibernatable upgrade) is now emitted, so the 3a platform-lock
2834    // that gated it off is removed.
2835    // Per-context native sets, with the context name kept for spans/messages.
2836    let mut per_context: Vec<(String, BTreeMap<Platform, String>)> = Vec::new();
2837    let mut names: Vec<&String> = groups.keys().collect();
2838    names.sort();
2839    for name in names {
2840        if kinds.get(name.as_str()) != Some(&UnitKind::Context) {
2841            continue;
2842        }
2843        let Some(table) = unit_tables.get(name.as_str()) else {
2844            continue;
2845        };
2846        let native = native_platforms_of_context(
2847            name,
2848            table,
2849            unit_tables,
2850            unit_consumes,
2851            unit_consumes_aliases,
2852            unit_flattened,
2853        );
2854        if !native.is_empty() {
2855            per_context.push((name.clone(), native));
2856        }
2857    }
2858    // The deployment units to check: per-context under workers; their union
2859    // under bundle (the whole program co-locates).
2860    let units: Vec<(String, BTreeMap<Platform, String>)> = match target {
2861        BuildTarget::Workers => per_context,
2862        BuildTarget::Bundle => {
2863            let mut union = BTreeMap::new();
2864            let mut owner: Option<String> = None;
2865            for (ctx, native) in per_context {
2866                owner.get_or_insert(ctx);
2867                for (p, unit) in native {
2868                    union.entry(p).or_insert(unit);
2869                }
2870            }
2871            match owner {
2872                Some(ctx) if !union.is_empty() => vec![(ctx, union)],
2873                _ => Vec::new(),
2874            }
2875        }
2876    };
2877    for (ctx, native) in units {
2878        let Some(violation) = lock_violation(&native, selected) else {
2879            continue;
2880        };
2881        let span_for = |unit: &str| {
2882            groups
2883                .get(&ctx)
2884                .and_then(|idx| consumes_span_of(parsed, idx, unit))
2885                .map(|(_, s)| s)
2886                .unwrap_or_default()
2887        };
2888        match violation {
2889            LockViolation::Required { needed, unit } => {
2890                errors.push_for(
2891                    None,
2892                    CompileError::new(
2893                        "bynk.target.vendor_required",
2894                        span_for(&unit),
2895                        format!(
2896                            "context `{ctx}` uses the platform-native capabilities of `{unit}`, which run only on the `{}` platform, but the build selects `--platform {}`",
2897                            needed.as_str(),
2898                            selected.as_str(),
2899                        ),
2900                    )
2901                    .with_note(
2902                        "build with the matching `--platform`, or remove the platform-native dependency to stay portable",
2903                    ),
2904                );
2905            }
2906            LockViolation::Conflict { a, b } => {
2907                errors.push_for(
2908                    None,
2909                    CompileError::new(
2910                        "bynk.target.vendor_conflict",
2911                        span_for(&a.1),
2912                        format!(
2913                            "one deployment unit (via context `{ctx}`) uses platform-native capabilities from two mutually-exclusive platforms: `{}` (from `{}`) and `{}` (from `{}`)",
2914                            a.0.as_str(),
2915                            a.1,
2916                            b.0.as_str(),
2917                            b.1,
2918                        ),
2919                    )
2920                    .with_note(
2921                        "split the consumers into separate deployment units (`--target workers`), or remove one of the platform-native dependencies",
2922                    ),
2923                );
2924            }
2925        }
2926    }
2927}
2928
2929/// v0.173 (ADR 0196 D1), P5.5 (`design/tracks/semantics-in-the-checker.md`
2930/// §6, §9): warn where a `bynk.Secrets` read names its secret with a computed
2931/// expression. Non-failing — the program is correct, `bynk deploy` simply
2932/// cannot see the name — walked per **file** rather than per unit, since a
2933/// merged `UnitTable` has thrown away which file a call site lives in and
2934/// [`ErrorSink::extend_for`] attributes a diagnostic to a path.
2935///
2936/// Gated on the Workers target because the whole consequence is about `bynk
2937/// deploy`'s plan, which no other target produces; warning a bundle project
2938/// about a deploy plan it will never produce would be noise. Relocated from
2939/// `bynk-emit::project::run_checks` — that call site's own comment claimed
2940/// this "reaches the editor" via `bynk check`/the LSP, which was true only
2941/// while the LSP still called `run_checks`'s `Mode::Analyse` arm; P4.2
2942/// repointed `bynk-ide` at [`crate::analysis::analyse_project`] instead, and
2943/// `bynk-check` cannot depend on `bynk-emit` to reach this code — so the
2944/// claim went stale silently, exactly the "ninth gap" §9 of the design doc
2945/// flagged as a risk rather than a scoped relocation. Wired into
2946/// `analyse_project` at the same relative point `run_checks` calls it,
2947/// mirroring [`phase_platform_lock`]'s own treatment of a build-target-gated
2948/// check: `analyse_project` hardcodes `BuildTarget::Bundle`, so this closes
2949/// the category structurally (R3.5 — the diagnostic now originates in
2950/// `bynk-check`), not observably, the same as categories 1 and 5.
2951pub fn phase_secrets_computed_name(
2952    target: BuildTarget,
2953    parsed: &[ParsedFile],
2954    groups: &BTreeMap<String, Vec<usize>>,
2955    kinds: &BTreeMap<String, UnitKind>,
2956    unit_flattened: &HashMap<String, HashMap<String, String>>,
2957    errors: &mut ErrorSink,
2958) {
2959    if target != BuildTarget::Workers {
2960        return;
2961    }
2962    for (name, indices) in groups {
2963        if kinds.get(name) != Some(&UnitKind::Context) {
2964            continue;
2965        }
2966        let Some(flattened) = unit_flattened.get(name) else {
2967            continue;
2968        };
2969        for &i in indices {
2970            let SourceUnit::Context(ctx) = &parsed[i].unit() else {
2971                continue;
2972            };
2973            let handlers = ctx.items.iter().filter_map(|item| match item {
2974                CommonsItem::Service(s) => Some(s.handlers.iter()),
2975                _ => None,
2976            });
2977            let (_, warnings) = crate::secrets::secret_reads_of(handlers.flatten(), flattened);
2978            let rel = parsed[i].identity_path();
2979            errors.extend_for(Some(&rel), warnings);
2980        }
2981    }
2982}
2983
2984/// Phase 7: build each production unit's file-declaration index (which file in
2985/// the unit declares which name), for cross-file lookups in the back half.
2986pub fn phase_file_index(
2987    groups: &BTreeMap<String, Vec<usize>>,
2988    parsed: &[ParsedFile],
2989) -> HashMap<String, FileDeclIndex> {
2990    let mut unit_file_index: HashMap<String, FileDeclIndex> = HashMap::new();
2991    for (name, indices) in groups {
2992        unit_file_index.insert(name.clone(), build_file_decl_index(indices, parsed));
2993    }
2994    unit_file_index
2995}
2996
2997/// v0.29.4: the per-unit facets that the producer phases build as nine parallel
2998/// `HashMap<String, _>`s, all keyed on unit name. Assembling one record per unit
2999/// makes the "all these maps share one keyset" invariant structural: a single
3000/// lookup yields every facet as a field, so the per-column `.unwrap()`s on the
3001/// shared keyset disappear. Fields are total — `exports`/`aliases`/`flattened`
3002/// default to an empty map for a unit with no entry, reproducing the old
3003/// `.unwrap_or(empty)` read semantics without the dance.
3004pub struct UnitInfo {
3005    pub kind: UnitKind,
3006    pub table: UnitTable,
3007    pub uses: Vec<String>,
3008    pub consumes: Vec<String>,
3009    pub flattened: HashMap<String, String>,
3010    pub aliases: HashMap<String, String>,
3011    pub exports: HashMap<String, Visibility>,
3012    pub file_index: FileDeclIndex,
3013    pub files: Vec<usize>,
3014}
3015
3016/// v0.29.4: fold the nine parallel per-unit maps into one `HashMap<String,
3017/// UnitInfo>`. Assembly is driven by the `groups` keyset (the authority), so
3018/// every group yields exactly one record. Facets that are genuinely optional in
3019/// the producer maps (`exports`/`aliases`/`flattened`, and `file_index` for a
3020/// unit with no declarations) default to empty — reproducing the old
3021/// `.unwrap_or(empty)` read semantics as a total field.
3022#[allow(clippy::too_many_arguments)]
3023pub fn assemble_unit_info(
3024    groups: &BTreeMap<String, Vec<usize>>,
3025    kinds: &BTreeMap<String, UnitKind>,
3026    unit_tables: &HashMap<String, UnitTable>,
3027    unit_uses: &HashMap<String, Vec<String>>,
3028    unit_consumes: &HashMap<String, Vec<String>>,
3029    unit_flattened: &HashMap<String, HashMap<String, String>>,
3030    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3031    exports_visibility: &HashMap<String, HashMap<String, Visibility>>,
3032    unit_file_index: &HashMap<String, FileDeclIndex>,
3033) -> BTreeMap<String, UnitInfo> {
3034    groups
3035        .iter()
3036        .map(|(name, indices)| {
3037            let info = UnitInfo {
3038                kind: *kinds.get(name).unwrap(),
3039                table: unit_tables.get(name).unwrap().clone(),
3040                uses: unit_uses.get(name).cloned().unwrap_or_default(),
3041                consumes: unit_consumes.get(name).cloned().unwrap_or_default(),
3042                flattened: unit_flattened.get(name).cloned().unwrap_or_default(),
3043                aliases: unit_consumes_aliases.get(name).cloned().unwrap_or_default(),
3044                exports: exports_visibility.get(name).cloned().unwrap_or_default(),
3045                file_index: unit_file_index
3046                    .get(name)
3047                    .cloned()
3048                    .unwrap_or_else(|| FileDeclIndex {
3049                        types: HashMap::new(),
3050                        fns: HashMap::new(),
3051                        methods: HashMap::new(),
3052                    }),
3053                files: indices.clone(),
3054            };
3055            (name.clone(), info)
3056        })
3057        .collect()
3058}
3059
3060/// Phase 8c: collect every method authored anywhere in one unit, keyed by its
3061/// attached type's name — so a type's methods surface in the file that declares
3062/// the type even when the method lives in a sibling file. The collection loop
3063/// has no `continue`s, so it lifts out whole.
3064pub fn collect_unit_methods(
3065    indices: &[usize],
3066    parsed: &[ParsedFile],
3067) -> HashMap<String, Vec<FnDecl>> {
3068    let mut local_methods_for_type: HashMap<String, Vec<FnDecl>> = HashMap::new();
3069    for &j in indices {
3070        for item in parsed[j].items() {
3071            if let CommonsItem::Fn(f) = item
3072                && let FnName::Method { type_name, .. } = &f.name
3073            {
3074                local_methods_for_type
3075                    .entry(type_name.name.clone())
3076                    .or_default()
3077                    .push(f.clone());
3078            }
3079        }
3080    }
3081    local_methods_for_type
3082}
3083
3084/// Phase 8b: merge one context's `consumes` exports into the composed symbol
3085/// space, recording visibility metadata in the returned `consumed_types`. The
3086/// per-export `continue`s (missing decl, name conflict) stay internal to the
3087/// loop, which lifts out whole; name conflicts are pushed into `errors` and the
3088/// caller's `group_error_baseline` guard reacts to them after this returns.
3089#[allow(clippy::too_many_arguments)]
3090pub fn merge_consumed_exports(
3091    name: &str,
3092    parsed: &[ParsedFile],
3093    unit_info: &BTreeMap<String, UnitInfo>,
3094    combined_types: &mut HashMap<String, Arc<TypeDecl>>,
3095    combined_methods: &mut HashMap<String, ResolverMethodTable>,
3096    imported_from: &mut HashMap<String, String>,
3097    imported_from_kind: &mut HashMap<String, UnitKind>,
3098    errors: &mut ErrorSink,
3099) -> HashMap<String, ConsumedType> {
3100    // Names visible from `consumes` (read-only types from consumed contexts).
3101    // For each name we track:
3102    // - the type decl, with the consumed context's identity
3103    // - the visibility (opaque/transparent)
3104    // - the owning context's qualified name (for external-construction errors)
3105    let mut consumed_types: HashMap<String, ConsumedType> = HashMap::new();
3106
3107    // Now process `consumes` for contexts: add exported types into the
3108    // symbol table with visibility metadata so the checker can enforce
3109    // construction / inspection rules.
3110    for t in unit_info.get(name).into_iter().flat_map(|i| &i.consumes) {
3111        let used = &unit_info.get(t).expect("consumed unit present").table;
3112        let used_exports = &unit_info[t].exports;
3113        for (type_name, vis) in used_exports {
3114            let Some(decl) = used.types.get(type_name) else {
3115                continue;
3116            };
3117            if combined_types.contains_key(type_name) {
3118                // Name conflict between local/uses and consumed export.
3119                let consumes_site = consumes_span_of(parsed, &unit_info[name].files, t);
3120                let consumes_span = consumes_site.map(|(_, s)| s).unwrap_or_default();
3121                let consumes_file = consumes_site.map(|(i, _)| parsed[i].identity_path());
3122                errors.push_for(consumes_file.as_deref(),
3123                    CompileError::new(
3124                        "bynk.consumes.name_conflict",
3125                        consumes_span,
3126                        format!(
3127                            "context `{name}` consumes `{t}` which exports type `{type_name}`, but a type of the same name is already in scope",
3128                        ),
3129                    )
3130                    .with_note(
3131                        "rename one of the conflicting declarations or restructure the import",
3132                    ),
3133                );
3134                continue;
3135            }
3136            combined_types.insert(type_name.clone(), decl.clone());
3137            imported_from.insert(type_name.clone(), t.clone());
3138            imported_from_kind.insert(type_name.clone(), UnitKind::Context);
3139            consumed_types.insert(
3140                type_name.clone(),
3141                ConsumedType {
3142                    owning_context: t.clone(),
3143                    visibility: *vis,
3144                },
3145            );
3146            // Methods on transparently-exported types: they're emitted in
3147            // the owning context's output, but reading-side methods (like
3148            // user-declared instance methods) are callable from consumers.
3149            // For v0.4, we expose all instance methods on consumed types
3150            // so the checker can resolve method calls; the checker
3151            // separately enforces that constructors (.of/unsafe) aren't
3152            // callable externally.
3153            if let Some(mt) = used.methods.get(type_name) {
3154                let entry = combined_methods.entry(type_name.clone()).or_default();
3155                for (m, decl) in &mt.instance {
3156                    entry
3157                        .instance
3158                        .entry(m.clone())
3159                        .or_insert_with(|| decl.clone());
3160                }
3161                // We deliberately *don't* import static methods from
3162                // consumed contexts. Static methods can construct new
3163                // values, which is forbidden externally.
3164            }
3165        }
3166    }
3167
3168    consumed_types
3169}
3170
3171/// Phase 8a: compose one unit's symbol space — its local table plus a
3172/// one-level `uses` mixin (commons identity preserved). Returns the combined
3173/// type/fn/method tables and the `imported_from` provenance maps; the mixin
3174/// loop has no `continue`s, so it lifts out whole.
3175#[allow(clippy::type_complexity)]
3176pub fn compose_unit_symbols(
3177    name: &str,
3178    local_table: &UnitTable,
3179    unit_info: &BTreeMap<String, UnitInfo>,
3180) -> (
3181    HashMap<String, Arc<TypeDecl>>,
3182    HashMap<String, Arc<FnDecl>>,
3183    HashMap<String, ResolverMethodTable>,
3184    HashMap<String, String>,
3185    HashMap<String, UnitKind>,
3186) {
3187    // Compose: local + transitive (one level) uses. For commons, mixin
3188    // preserves type identity; for contexts, mixin produces per-context
3189    // nominal types. The resolver doesn't distinguish (the rebranding is
3190    // observable in emission); the symbol table union is the same.
3191    let mut combined_types = local_table.types.clone();
3192    let mut combined_fns = local_table.fns.clone();
3193    let mut combined_methods = local_table.methods.clone();
3194    let mut imported_from: HashMap<String, String> = HashMap::new();
3195    let mut imported_from_kind: HashMap<String, UnitKind> = HashMap::new();
3196
3197    for t in unit_info.get(name).into_iter().flat_map(|i| &i.uses) {
3198        let used = &unit_info.get(t).expect("used unit present").table;
3199        for (type_name, decl) in &used.types {
3200            if !combined_types.contains_key(type_name) {
3201                combined_types.insert(type_name.clone(), decl.clone());
3202                imported_from.insert(type_name.clone(), t.clone());
3203                imported_from_kind.insert(type_name.clone(), UnitKind::Commons);
3204            }
3205        }
3206        for (fn_name, decl) in &used.fns {
3207            if !combined_fns.contains_key(fn_name) {
3208                combined_fns.insert(fn_name.clone(), decl.clone());
3209                imported_from.insert(fn_name.clone(), t.clone());
3210                imported_from_kind.insert(fn_name.clone(), UnitKind::Commons);
3211            }
3212        }
3213        for (type_name, mt) in &used.methods {
3214            let entry = combined_methods.entry(type_name.clone()).or_default();
3215            for (m, decl) in &mt.instance {
3216                entry
3217                    .instance
3218                    .entry(m.clone())
3219                    .or_insert_with(|| decl.clone());
3220            }
3221            for (m, decl) in &mt.statics {
3222                entry
3223                    .statics
3224                    .entry(m.clone())
3225                    .or_insert_with(|| decl.clone());
3226            }
3227        }
3228    }
3229
3230    (
3231        combined_types,
3232        combined_fns,
3233        combined_methods,
3234        imported_from,
3235        imported_from_kind,
3236    )
3237}
3238
3239/// Phase 5c: detect `consumes` cycles. #696: record each `consumes`-clause
3240/// site (file + span) keyed by `(consumer, target)` so a detected cycle
3241/// anchors on the exact clause that forms the closing edge — a real span in
3242/// a real file — and renders with source context. Synthetic units are left
3243/// out so their (snapshot-less) files never claim a diagnostic.
3244pub fn phase_detect_consumes_cycles(
3245    groups: &BTreeMap<String, Vec<usize>>,
3246    parsed: &[ParsedFile],
3247    unit_consumes: &HashMap<String, Vec<String>>,
3248    errors: &mut ErrorSink,
3249) {
3250    let mut consumes_sites: HashMap<(String, String), (PathBuf, Span)> = HashMap::new();
3251    for (name, indices) in groups {
3252        for &i in indices {
3253            if parsed[i].is_synthetic() {
3254                continue;
3255            }
3256            for c in parsed[i].consumes() {
3257                consumes_sites
3258                    .entry((name.clone(), c.target.joined()))
3259                    .or_insert_with(|| (parsed[i].identity_path(), c.span));
3260            }
3261        }
3262    }
3263    let mut cycle_errors: Vec<(Option<PathBuf>, CompileError)> = Vec::new();
3264    detect_consumes_cycles(unit_consumes, &consumes_sites, &mut cycle_errors);
3265    for (path, err) in cycle_errors {
3266        errors.push_for(path.as_deref(), err);
3267    }
3268}
3269
3270#[cfg(test)]
3271mod platform_lock_tests {
3272    use super::{LockViolation, Platform, lock_violation};
3273    use std::collections::BTreeMap;
3274
3275    fn native(entries: &[(Platform, &str)]) -> BTreeMap<Platform, String> {
3276        entries
3277            .iter()
3278            .map(|(p, u)| (*p, (*u).to_string()))
3279            .collect()
3280    }
3281
3282    #[test]
3283    fn empty_closure_imposes_no_lock() {
3284        assert_eq!(lock_violation(&native(&[]), Platform::Node), None);
3285    }
3286
3287    #[test]
3288    fn matching_platform_is_fine() {
3289        let n = native(&[(Platform::Cloudflare, "bynk.cloudflare")]);
3290        assert_eq!(lock_violation(&n, Platform::Cloudflare), None);
3291    }
3292
3293    #[test]
3294    fn mismatched_platform_is_required() {
3295        let n = native(&[(Platform::Cloudflare, "bynk.cloudflare")]);
3296        assert_eq!(
3297            lock_violation(&n, Platform::Node),
3298            Some(LockViolation::Required {
3299                needed: Platform::Cloudflare,
3300                unit: "bynk.cloudflare".to_string(),
3301            })
3302        );
3303    }
3304
3305    // The conflict arm is not yet reachable end-to-end (only one platform
3306    // ships native capabilities until `bynk.aws`); the rule is exercised here
3307    // with a synthetic two-platform set so it does not ship untested
3308    // (proposal v0.19, review call).
3309    #[test]
3310    fn two_platforms_conflict_regardless_of_selection() {
3311        let n = native(&[
3312            (Platform::Cloudflare, "bynk.cloudflare"),
3313            (Platform::Node, "bynk.synthetic"),
3314        ]);
3315        let v = lock_violation(&n, Platform::Cloudflare);
3316        assert_eq!(
3317            v,
3318            Some(LockViolation::Conflict {
3319                a: (Platform::Cloudflare, "bynk.cloudflare".to_string()),
3320                b: (Platform::Node, "bynk.synthetic".to_string()),
3321            })
3322        );
3323    }
3324}
3325
3326#[cfg(test)]
3327mod native_platform_closure_tests {
3328    use super::{HashMap, Platform, UnitTable, native_platforms_of_context};
3329    use bynk_syntax::ast::{CapRef, Ident, ProviderDecl, QualifiedName};
3330    use bynk_syntax::span::Span;
3331    use std::collections::HashMap as StdHashMap;
3332
3333    fn ident(name: &str) -> Ident {
3334        Ident {
3335            name: name.to_string(),
3336            span: Span::default(),
3337        }
3338    }
3339
3340    fn qualified(parts: &[&str]) -> QualifiedName {
3341        QualifiedName {
3342            parts: parts.iter().map(|p| ident(p)).collect(),
3343            span: Span::default(),
3344        }
3345    }
3346
3347    fn given_cap(prefix: Option<&[&str]>, name: &str) -> CapRef {
3348        CapRef {
3349            context: prefix.map(qualified),
3350            name: ident(name),
3351            span: Span::default(),
3352        }
3353    }
3354
3355    fn provider(capability: &str, given: Vec<CapRef>) -> ProviderDecl {
3356        ProviderDecl {
3357            capability: ident(capability),
3358            provider_name: ident(&format!("{capability}Impl")),
3359            given,
3360            ops: Vec::new(),
3361            external: false,
3362            documentation: None,
3363            span: Span::default(),
3364            trivia: Default::default(),
3365        }
3366    }
3367
3368    fn empty_table() -> UnitTable {
3369        UnitTable {
3370            kind: None,
3371            types: StdHashMap::new(),
3372            fns: StdHashMap::new(),
3373            methods: StdHashMap::new(),
3374            capabilities: StdHashMap::new(),
3375            providers: StdHashMap::new(),
3376            services: StdHashMap::new(),
3377            agents: StdHashMap::new(),
3378            actors: StdHashMap::new(),
3379            exported_capabilities: Default::default(),
3380            events: StdHashMap::new(),
3381        }
3382    }
3383
3384    /// P5.3 review finding (#1133): nothing in the tree exercised
3385    /// `collect_given_closure`'s recursive arm — every existing fixture that
3386    /// reaches `bynk.cloudflare` does so through a handler's bare `given Kv`
3387    /// (`handler_cross_caps`, depth 0: `provider.given.is_empty()` short-
3388    /// circuits immediately), never through a local provider's own `given`
3389    /// chain. This pins the contract `collect_given_closure`'s own doc
3390    /// states: a context whose *only* path to a platform-native unit is a
3391    /// provider's `given` — `provides Cache = LocalCache given
3392    /// bynk.cloudflare.Kv { … }`, with no handler ever naming `Kv` directly —
3393    /// must still be recognised as native. `bynkc/tests/fixtures/negative/
3394    /// 1030_kv_provider_given_wrong_platform` pins the same contract
3395    /// end-to-end through `run_checks`.
3396    #[test]
3397    fn a_providers_given_chain_into_a_platform_native_unit_is_recognised() {
3398        let mut table = empty_table();
3399        table.providers.insert(
3400            "Cache".to_string(),
3401            provider(
3402                "Cache",
3403                vec![given_cap(Some(&["bynk", "cloudflare"]), "Kv")],
3404            ),
3405        );
3406        let mut unit_tables = HashMap::new();
3407        unit_tables.insert("app.web".to_string(), table);
3408        let mut unit_consumes = HashMap::new();
3409        unit_consumes.insert("app.web".to_string(), vec!["bynk.cloudflare".to_string()]);
3410
3411        let native = native_platforms_of_context(
3412            "app.web",
3413            unit_tables.get("app.web").unwrap(),
3414            &unit_tables,
3415            &unit_consumes,
3416            &HashMap::new(),
3417            &HashMap::new(),
3418        );
3419        assert_eq!(
3420            native.get(&Platform::Cloudflare).map(String::as_str),
3421            Some("bynk.cloudflare"),
3422            "a provider's own `given` closure into a platform-native unit must be \
3423             walked recursively, not just a handler's direct `given` — got {native:?}"
3424        );
3425    }
3426
3427    /// A provider whose `given` closure never leaves ordinary (non-native)
3428    /// units contributes nothing — the recursive walk must not manufacture a
3429    /// platform out of thin air.
3430    #[test]
3431    fn a_providers_given_chain_into_an_ordinary_unit_is_not_native() {
3432        let mut table = empty_table();
3433        table.providers.insert(
3434            "Cache".to_string(),
3435            provider("Cache", vec![given_cap(None, "Clock")]),
3436        );
3437        let mut unit_tables = HashMap::new();
3438        unit_tables.insert("app.web".to_string(), table);
3439
3440        let native = native_platforms_of_context(
3441            "app.web",
3442            unit_tables.get("app.web").unwrap(),
3443            &unit_tables,
3444            &HashMap::new(),
3445            &HashMap::new(),
3446            &HashMap::new(),
3447        );
3448        assert!(
3449            native.is_empty(),
3450            "a `given` closure that never reaches a platform-native unit must not \
3451             report one — got {native:?}"
3452        );
3453    }
3454}