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