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