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, LabelFile, ParsedFile, UnitKind, check_directory_kind_consistency,
51    check_directory_name_consistency, check_file_directory_conflicts, check_group_kind_consistency,
52    check_group_module_docs, check_path_name_alignment, detect_consumes_cycles,
53    discover_bynk_files, is_unpinned_range, normalize_rel, parse_sources, parse_sources_recovering,
54    read_adapter_binding, read_adapter_include, read_source,
55};
56use bynk_syntax::ast::*;
57/// P6.49 (design/tracks/the-ir.md §6b), following P6.27's `ExprId` precedent
58/// (`checker.rs:39`): re-exported because this module's own public API —
59/// [`compose_unit_symbols`]'s `combined_types`/`combined_fns`,
60/// [`collect_unit_methods`]'s return, and [`UnitInfo::exports`]'s value type
61/// — is already parameterised by these three types. `bynk-emit` only ever
62/// plumbs the resulting tables through to other `bynk-check` calls; it never
63/// matches a variant of any of the three.
64pub use bynk_syntax::ast::{FnDecl, TypeDecl, Visibility};
65use bynk_syntax::error::CompileError;
66use bynk_syntax::lexer;
67use bynk_syntax::parser;
68use bynk_syntax::span::{FileId, Span};
69
70/// Collection-point error sink (ADR 0052). Helpers keep their plain
71/// `&mut Vec<CompileError>` signatures; call sites attribute via
72/// `extend_for` with the file in scope at that point.
73///
74/// P4.1 (#1115): relocated from `bynk-emit/src/project/diagnostics.rs`
75/// alongside the `phase_*` functions above, which all take `&mut ErrorSink` —
76/// the same "shared logic pulls its own types down with it" pattern already
77/// applied to `UnitTable`/`ConsumedType` in the `symbols.rs` move. `Mode` and
78/// `ProjectFailure` (the other two `diagnostics.rs` pipeline-driving types)
79/// stayed in `bynk-emit`, unaffected — neither is a dependency of anything
80/// this module needs.
81pub struct ErrorSink {
82    entries: Vec<AttributedError>,
83    /// v0.89 (ADR 0117): non-failing warnings, classified on push by
84    /// `Severity::for_error`. Kept apart so `is_empty`/`len` — the build-failure
85    /// gates — stay errors-only, while every warning source (commons-fn checks,
86    /// service/agent handler validation, parser) is captured uniformly.
87    warnings: Vec<AttributedError>,
88    /// The identity path of every parsed project file, by the `FileId` its
89    /// spans carry ([`Self::register_file`]). Read when the sink is drained,
90    /// to say which file each secondary label lies in (`label_files`).
91    files: HashMap<FileId, PathBuf>,
92}
93
94impl Default for ErrorSink {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl ErrorSink {
101    pub fn new() -> Self {
102        Self {
103            entries: Vec::new(),
104            warnings: Vec::new(),
105            files: HashMap::new(),
106        }
107    }
108    /// Record that spans carrying `file` lie in the project file `identity`.
109    pub fn register_file(&mut self, file: FileId, identity: PathBuf) {
110        self.files.insert(file, identity);
111    }
112    /// Register every file of `parsed` that was read from disk, by the durable
113    /// `FileId` its absolute path interns to (`parse_cache::file_id_for`) — the
114    /// one map both an error's file ([`Self::extend_attributed_by_span`]) and
115    /// each label's (when the sink is drained) are read from, so the two never
116    /// disagree. `phase_parse` registers every file it parses; a
117    /// toolchain-injected unit, with no absolute path, is never registered.
118    pub fn register_parsed(&mut self, parsed: &[ParsedFile]) {
119        for pf in parsed {
120            if let Some(abs) = pf.abs_path() {
121                self.register_file(
122                    bynk_project::parse_cache::file_id_for(&abs),
123                    pf.identity_path(),
124                );
125            }
126        }
127    }
128    pub fn push_for(&mut self, file: Option<&Path>, mut error: CompileError) {
129        // #2223: a dependency unit's canonical name reads as
130        // `org/package:unit` wherever a diagnostic names it.
131        display_canonical_names(&mut error);
132        let attributed = AttributedError::new(file.map(Path::to_path_buf), error);
133        match bynk_syntax::Severity::for_error(&attributed.error) {
134            bynk_syntax::Severity::Warning => self.warnings.push(attributed),
135            bynk_syntax::Severity::Error => self.entries.push(attributed),
136        }
137    }
138    pub fn extend_for(
139        &mut self,
140        file: Option<&Path>,
141        errs: impl IntoIterator<Item = CompileError>,
142    ) {
143        for e in errs {
144            self.push_for(file, e);
145        }
146    }
147    /// #1659 (closes #696's gap): attribute each error to the parsed file its
148    /// span is in, by `FileId`. Every parsed file's spans carry the durable
149    /// `FileId` its absolute path interns to (`parse_cache::file_id_for`), so
150    /// the owning file is recoverable from the span alone. Suite and
151    /// integration diagnostics were pushed unattributed, because threading a
152    /// file through their many internal push sites was deferred, and rendered
153    /// with no file, line or span. An error whose span matches no parsed file
154    /// stays unattributed, as before. `parsed` is registered first
155    /// ([`Self::register_parsed`]), so a file `phase_parse` never saw is still
156    /// found.
157    pub fn extend_attributed_by_span(
158        &mut self,
159        parsed: &[ParsedFile],
160        errs: impl IntoIterator<Item = CompileError>,
161    ) {
162        self.register_parsed(parsed);
163        for e in errs {
164            let file = self.files.get(&e.span.file).cloned();
165            self.push_for(file.as_deref(), e);
166        }
167    }
168    /// True when no **error-severity** diagnostic has been collected — the
169    /// build-failure gate. Warnings do not count (ADR 0117).
170    pub fn is_empty(&self) -> bool {
171        self.entries.is_empty()
172    }
173    /// Consume the sink, yielding the non-failing **warnings** (ADR 0117).
174    pub fn into_warnings(self) -> Vec<AttributedError> {
175        let mut warnings = self.warnings;
176        attribute_labels(&self.files, &mut warnings);
177        warnings
178    }
179    /// Consume the sink, yielding errors then warnings — the full diagnostic
180    /// list the LSP and a failed build render together.
181    pub fn into_all(self) -> Vec<AttributedError> {
182        let mut all = self.entries;
183        all.extend(self.warnings);
184        attribute_labels(&self.files, &mut all);
185        all
186    }
187    /// The count of **error-severity** diagnostics.
188    pub fn len(&self) -> usize {
189        self.entries.len()
190    }
191}
192
193/// Fill in each error's `label_files` from the `FileId` its labels' spans
194/// carry, against the files `phase_parse` registered. A label is in the
195/// error's own file when its span has the primary span's `FileId`, carries no
196/// file identity (`FileId::UNKNOWN`, a span built without one), or names the
197/// error's own `source_path`. One naming another registered file is
198/// [`LabelFile::Other`]; one naming a toolchain-injected unit's reserved id is
199/// [`LabelFile::FirstParty`] (#1956), carrying that unit's own source; any other
200/// is [`LabelFile::Unavailable`]. Neither is ever drawn against the primary
201/// file's text.
202fn attribute_labels(files: &HashMap<FileId, PathBuf>, errors: &mut [AttributedError]) {
203    for ae in errors {
204        let primary = ae.error.span.file;
205        ae.label_files = ae
206            .error
207            .labels
208            .iter()
209            .map(|(span, _)| {
210                if span.file == primary || span.file == FileId::UNKNOWN {
211                    return LabelFile::Primary;
212                }
213                match files.get(&span.file) {
214                    Some(path) if Some(path) == ae.source_path.as_ref() => LabelFile::Primary,
215                    Some(path) => LabelFile::Other(path.clone()),
216                    None => match firstparty_unit_for(span.file) {
217                        Some(unit) => LabelFile::FirstParty {
218                            identity_path: unit.identity_path,
219                            source: unit.source,
220                        },
221                        None => LabelFile::Unavailable,
222                    },
223                }
224            })
225            .collect();
226    }
227}
228
229/// v0.17: a resolved adapter binding — the user-authored `.binding.ts` module
230/// that supplies an adapter's external provider symbols. Copied verbatim into
231/// the output beside the adapter's emitted interface module so that `tsc`
232/// checks the `implements` contract and compose can import the symbols.
233pub struct AdapterBinding {
234    /// Output path, relative to the output root (e.g. `tokens.binding.ts`).
235    pub output_path: PathBuf,
236    /// Verbatim TypeScript content read from the source tree.
237    pub content: String,
238    /// #2321: the files the `binding … includes [ … ]` clause ships beside it.
239    pub includes: Vec<IncludedFile>,
240}
241
242/// #2321: one file an adapter binding includes, copied byte for byte.
243#[derive(Clone)]
244pub struct IncludedFile {
245    /// Output path, relative to the output root (beside the binding).
246    pub output_path: PathBuf,
247    pub bytes: Vec<u8>,
248}
249
250/// The build target. Determines how cross-context calls and per-context
251/// modules are emitted (v0.8). Bundle mode is the default — all contexts
252/// emit into one TypeScript bundle and cross-context calls are direct
253/// function invocations. Workers mode produces per-context Cloudflare
254/// Worker bundles that communicate via Service Bindings.
255#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
256pub enum BuildTarget {
257    /// Existing behaviour: one TS bundle, direct function calls between
258    /// contexts.
259    #[default]
260    Bundle,
261    /// One Worker per context. Cross-context calls become Service Binding
262    /// invocations using a JSON wire format with refinement validation on
263    /// the receiving side.
264    Workers,
265}
266
267pub fn normalize_service_defaults(parsed: &mut [ParsedFile]) {
268    for pf in parsed.iter_mut() {
269        let items = match pf.unit_mut() {
270            SourceUnit::Commons(c) => &mut c.items,
271            SourceUnit::Context(c) => &mut c.items,
272            SourceUnit::Adapter(a) => &mut a.items,
273            SourceUnit::Suite(_) => continue,
274        };
275        for item in items.iter_mut() {
276            if let CommonsItem::Service(svc) = item {
277                inject_service_defaults(svc);
278            }
279        }
280    }
281}
282
283/// Inject a single service's `by`/`given` defaults into its handlers. A handler
284/// that names its own `by` (or `given`) overrides the default outright — the
285/// default fills only an *absent* clause, never merges. A service with no default
286/// is left untouched (byte-for-byte the pre-v0.155 behaviour).
287pub fn inject_service_defaults(svc: &mut ServiceDecl) {
288    let default_by = svc.default_by.clone();
289    let default_given = svc.default_given.clone();
290    if default_by.is_none() && default_given.is_empty() {
291        return;
292    }
293    for handler in svc.handlers.iter_mut() {
294        // #2341 (decision C): `on rejected` runs for a request no handler took,
295        // before any actor is known, and uses no capability.
296        if handler.kind == HandlerKind::Rejected {
297            continue;
298        }
299        if handler.by_clause.is_none()
300            && let Some(def) = &default_by
301        {
302            handler.by_clause = Some(def.clone());
303        }
304        if handler.given.is_empty() && !default_given.is_empty() {
305            handler.given = default_given.clone();
306        }
307    }
308}
309
310/// Phase 1: discover the `.bynk` files under the source (and, in split mode,
311/// the tests) root by walking the filesystem. Pushes any discovery error into
312/// `errors` and signals a pipeline bail via `Err(())` (the caller terminates
313/// with `finish`); otherwise returns the discovered `(src_files, tests_files)`.
314///
315/// #1077/#1081 review: this is the on-disk half only — `no_sources`/
316/// `check_file_directory_conflicts` moved to [`check_discovered_files`], which
317/// `run_checks` calls on the result *either* this walk *or* a caller-supplied
318/// `discovered` list produces, so a `CompileOptions.sources`-driven compile
319/// (the CLI's own path as of #1081) still gets both checks — they are
320/// properties of "what files does this build have," not of having just
321/// walked the disk to find them.
322/// R3.9 (#1113): walks every `(root, prefix)` tree `Roots::trees` resolves
323/// to, not a hardcoded primary/secondary pair — `trees[0]` is the mandatory
324/// tree (a missing directory is a real error, via `discover_bynk_files`
325/// itself); every later tree is optional, same as the old secondary tree
326/// always was (a project may simply have no such subtree).
327#[allow(clippy::result_unit_err)]
328pub fn phase_discovery(
329    trees: &[(PathBuf, PathBuf)],
330    excludes: &[PathBuf],
331    errors: &mut ErrorSink,
332) -> Result<Vec<Vec<PathBuf>>, ()> {
333    let mut out = Vec::with_capacity(trees.len());
334    for (i, (root, _prefix)) in trees.iter().enumerate() {
335        match discover_bynk_files(root, excludes) {
336            Ok(f) => out.push(f),
337            // Every tree past the first is optional — a missing directory is
338            // not an error, same as the old secondary tree always was. Tried
339            // via `discover_bynk_files` itself (a `fs::read_dir`) rather than
340            // a `root.exists()` pre-check, which would cost a redundant
341            // `stat()` per optional tree for the same answer.
342            Err(e) if i > 0 && e.category == "bynk.project.no_root" => {
343                out.push(Vec::new());
344            }
345            Err(e) => {
346                errors.push_for(None, e);
347                return Err(());
348            }
349        }
350    }
351    Ok(out)
352}
353
354/// The checks every tree's file list must pass regardless of where it came
355/// from — a real disk walk ([`phase_discovery`]) or a caller-supplied
356/// `discovered`/`CompileOptions.sources` list (#1077/#1081 review). An empty
357/// project (`bynk.project.no_sources`) signals a bail via `Err(())`; a
358/// file/directory name conflict is a non-fatal diagnostic.
359#[allow(clippy::result_unit_err)]
360pub fn check_discovered_files(
361    trees: &[(PathBuf, PathBuf)],
362    file_lists: &[Vec<PathBuf>],
363    errors: &mut ErrorSink,
364) -> Result<(), ()> {
365    if file_lists.iter().all(|f| f.is_empty()) {
366        errors.push_for(
367            None,
368            CompileError::new(
369                "bynk.project.no_sources",
370                Span::default(),
371                format!(
372                    "no `.bynk` source files found under {}",
373                    trees[0].0.display()
374                ),
375            ),
376        );
377        return Err(());
378    }
379    for ((root, _prefix), files) in trees.iter().zip(file_lists.iter()) {
380        if let Err(e) = check_file_directory_conflicts(root, files) {
381            errors.extend_for(None, e);
382        }
383    }
384    Ok(())
385}
386
387/// A memoized parse of one first-party synthetic source, keyed by the
388/// caller's `cache` — `inject_firstparty` passes one per registry entry.
389/// Finding #55/#65: the source text is a fixed `include_str!` constant, so its
390/// parse is a pure function of that constant and only needs computing once per
391/// process, not once per compile/analyse round. Which units a project injects
392/// is still decided fresh for every project from its own parsed
393/// `uses`/`consumes`; only the parse *result* is cached.
394/// T3.4 (R2.4): each first-party synthetic unit reserves its own 1M-wide
395/// `ExprId` block, spaced far above anything a real project's own file count
396/// could ever reach — see [`firstparty_parsed`]'s doc comment for why a fixed
397/// reservation, not a threaded counter, is the right shape here.
398pub const FIRSTPARTY_ID_BLOCK: u32 = 1_000_000;
399pub const FIRSTPARTY_ID_BASE: u32 = 1_000_000_000;
400
401/// #1956: the first `FileId` reserved for first-party synthetic units. Each
402/// unit's spans carry `FIRSTPARTY_FILE_ID_BASE + id_block` ([`firstparty_file_id`]),
403/// fixed for the same reason as its `ExprId` block: the parse is cached per
404/// process, so the id can't come from any one project's interner. The range
405/// sits just below `FileId::UNKNOWN` (`u32::MAX`), far above any id
406/// `parse_cache::file_id_for` hands a real file. Without an identity of their
407/// own, a first-party span counted as the error's own file, and a "declared
408/// here" label into `bynk.bynk` was drawn at the same offsets in the user's.
409pub const FIRSTPARTY_FILE_ID_BASE: u32 = 0xFFFF_0000;
410
411/// The `FileId` every span of first-party `unit` carries.
412pub fn firstparty_file_id(unit: &firstparty::Unit) -> FileId {
413    FileId(FIRSTPARTY_FILE_ID_BASE + unit.id_block)
414}
415
416/// The first-party unit whose spans carry `file`, if any.
417fn firstparty_unit_for(file: FileId) -> Option<&'static firstparty::Unit> {
418    firstparty::UNITS
419        .iter()
420        .find(|unit| firstparty_file_id(unit) == file)
421}
422
423pub fn firstparty_parsed(
424    cache: &'static OnceLock<Result<ParsedFile, Vec<CompileError>>>,
425    identity_path: &'static str,
426    src: &'static str,
427    kind: UnitKind,
428    // T3.4 (R2.4): a fixed base, not a live project counter — `cache` is a
429    // `OnceLock`, parsed once per *process*, and reused as-is across every
430    // later compile in that process regardless of how many real files that
431    // *particular* compile happens to have. A threaded counter can't work
432    // here (this parse doesn't know, and must never depend on, which compile
433    // triggers it first); a fixed, permanently-reserved range that no real
434    // project could ever grow into does. Call sites space their bases
435    // `FIRSTPARTY_ID_BLOCK` apart, one block per registry entry
436    // (`firstparty::Unit::id_block`), so first-party units can never collide
437    // with each other either, however many of them one project injects.
438    id_base: u32,
439    // #1956: the unit's reserved `FileId` ([`firstparty_file_id`]), stamped on
440    // every span so a label into it is never taken for the user's file.
441    file: FileId,
442) -> Result<ParsedFile, Vec<CompileError>> {
443    cache
444        .get_or_init(|| {
445            lexer::tokenize_in(src, file)
446                .map_err(|e| vec![e])
447                .and_then(|toks| {
448                    parser::parse_unit_with_warnings_from(&toks, src, &mut { id_base })
449                        .map(|(unit, _warnings)| unit)
450                })
451                .map(|unit| {
452                    ParsedFile::synthetic(
453                        PathBuf::from(identity_path),
454                        PathBuf::from(identity_path),
455                        src.to_string(),
456                        unit,
457                        kind,
458                    )
459                })
460        })
461        .clone()
462}
463
464/// #1663: every syntax error in `source`, from a recovering parse, when the
465/// strict parse failed with `strict` (its first), merged with it
466/// ([`bynk_syntax::parser::merge_syntax_errors`]). A lex error has no recovery.
467fn all_syntax_errors(source: &str, strict: Vec<CompileError>) -> Vec<CompileError> {
468    let Ok(tokens) = bynk_syntax::lexer::tokenize(source) else {
469        return strict;
470    };
471    let recovered = bynk_syntax::parser::parse_units_recovering(&tokens, source).errors;
472    bynk_syntax::parser::merge_syntax_errors(strict, recovered)
473}
474
475/// Phase 2: parse every discovered file into a `ParsedFile`, recording each
476/// file's source text into `snapshots` and any parse errors into `errors`.
477/// Then inject the first-party units the project consumes or uses
478/// (`inject_firstparty`). Returns the parsed units, the registry entries
479/// injected, and (#1710) each unit's [`BrokenDeclNames`]; signals a pipeline
480/// bail via `Err(())` when parsing produced errors and yielded no units at all.
481#[allow(clippy::too_many_arguments)]
482#[allow(clippy::result_unit_err)]
483pub fn phase_parse(
484    // R3.9 (#1113): one `(root, prefix)` pair per `Roots::trees` entry, not a
485    // hardcoded primary/secondary pair — every `include` tree is walked.
486    trees: &[(PathBuf, PathBuf)],
487    file_lists: &[Vec<PathBuf>],
488    overlay: &HashMap<PathBuf, String>,
489    errors: &mut ErrorSink,
490    snapshots: &mut Vec<(PathBuf, String)>,
491) -> Result<(Vec<ParsedFile>, InjectedUnits, BrokenDeclNames), ()> {
492    let mut parsed: Vec<ParsedFile> = Vec::new();
493    let mut broken: BrokenDeclNames = HashMap::new();
494    // P8.4 (#1515): `FileId`/`ExprId` allocation is no longer threaded through
495    // this function — `parse_sources` now resolves both through
496    // `bynk_project::parse_cache`'s own durable, process-lifetime counters
497    // (see that module's own doc comment, [DECISION D]), which trivially
498    // keeps the old within-one-call uniqueness guarantee T3.4/T3.5 named
499    // (global uniqueness implies uniqueness within any one call).
500    let parse_tree = |root: &Path,
501                      prefix: &Path,
502                      files: &[PathBuf],
503                      parsed: &mut Vec<ParsedFile>,
504                      broken: &mut BrokenDeclNames,
505                      errors: &mut ErrorSink,
506                      snapshots: &mut Vec<(PathBuf, String)>| {
507        for path in files {
508            // Tree-relative: what unit validation reads.
509            let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
510            // Slice 0 — project-relative: what *names* the file. Equal to `rel`
511            // for a single root (empty prefix).
512            let id = prefix.join(&rel);
513            let source = match read_source(path, overlay) {
514                Ok(s) => s,
515                Err(e) => {
516                    errors.push_for(
517                        Some(&id),
518                        CompileError::new(
519                            "bynk.project.read_failed",
520                            Span::default(),
521                            format!("could not read `{}`: {e}", path.display()),
522                        ),
523                    );
524                    continue;
525                }
526            };
527            snapshots.push((id.clone(), source.clone()));
528            match parse_sources(root, prefix, path, source) {
529                Ok((pfs, warnings)) => {
530                    parsed.extend(pfs);
531                    // ADR 0117: the sink classifies these as warnings — they
532                    // surface with the build but never gate it.
533                    errors.extend_for(Some(&id), warnings);
534                }
535                // #1663: the strict parse stops at a file's first syntax
536                // error. Report every one, from a recovering parse. #1710: the
537                // declarations that recovery kept are checked too, with the
538                // ones it skipped as known names (Decision B), so the file's
539                // other faults aren't hidden behind its syntax error. Nothing
540                // is emitted: the syntax errors already fail the build.
541                Err(errs) => {
542                    let source = snapshots.last().expect("pushed just above").1.clone();
543                    match parse_sources_recovering(root, prefix, path, source.clone()) {
544                        Some(recovered) => {
545                            errors.extend_for(
546                                Some(&id),
547                                bynk_syntax::parser::merge_syntax_errors(errs, recovered.errors),
548                            );
549                            // The parser records skipped names per file, so
550                            // each unit the file declares (a `commons` beside
551                            // its `suite`, say) gets them all: wider, but only
552                            // within the one file.
553                            for pf in &recovered.files {
554                                broken
555                                    .entry(pf.unit().name().joined())
556                                    .or_default()
557                                    .extend(recovered.broken_decl_names.iter().cloned());
558                            }
559                            parsed.extend(recovered.files);
560                        }
561                        None => errors.extend_for(Some(&id), all_syntax_errors(&source, errs)),
562                    }
563                }
564            }
565        }
566    };
567    for ((root, prefix), files) in trees.iter().zip(file_lists.iter()) {
568        parse_tree(
569            root,
570            prefix,
571            files,
572            &mut parsed,
573            &mut broken,
574            errors,
575            snapshots,
576        );
577    }
578    errors.register_parsed(&parsed);
579    if !errors.is_empty() && parsed.is_empty() {
580        return Err(());
581    }
582
583    let injected = inject_firstparty(&mut parsed, errors);
584    Ok((parsed, injected, broken))
585}
586
587/// The first-party units a project injected, as registry entries in ascending
588/// [`firstparty::Unit::id_block`] order. A unit whose source failed to parse is
589/// still listed, so its binding is still supplied. Its own `uses`/`consumes`
590/// cannot be read, though, so the units only it reaches are not injected: a
591/// malformed first-party source (which `every_first_party_source_parses` rejects
592/// in CI) reports its parse error and may add unresolved-name errors for those.
593pub type InjectedUnits = Vec<&'static firstparty::Unit>;
594
595/// Inject the first-party units the project reaches (standard-library S1,
596/// #1945). A unit in the [`firstparty::UNITS`] registry is reached when a parsed
597/// unit `consumes` it (an adapter) or `uses` it (a commons). The units it reaches
598/// in turn are its own source's `uses`/`consumes` lines, so injection repeats
599/// until nothing new is reached: the dependencies between first-party units
600/// (`bynk.map` uses `bynk.list`; the `bynk` adapter uses `bynk.locale.types`)
601/// come from their sources, never a hand-kept list. Each injected unit flows
602/// through the normal pipeline (tables, exports, emission, compose), and an
603/// adapter's binding is supplied by the toolchain (§4.2). Units are pushed in
604/// [`firstparty::Unit::id_block`] order, whatever order they were reached in.
605fn inject_firstparty(parsed: &mut Vec<ParsedFile>, errors: &mut ErrorSink) -> InjectedUnits {
606    const N: usize = firstparty::UNITS.len();
607    static CACHES: [OnceLock<Result<ParsedFile, Vec<CompileError>>>; N] =
608        [const { OnceLock::new() }; N];
609    let reaches = |pf: &ParsedFile, unit: &firstparty::Unit| match unit.kind {
610        firstparty::UnitKind::Adapter => {
611            pf.consumes().iter().any(|c| c.target.joined() == unit.name)
612        }
613        firstparty::UnitKind::Commons => pf.uses().iter().any(|u| u.target.joined() == unit.name),
614    };
615    let mut reached: Vec<Option<Option<ParsedFile>>> = vec![None; N];
616    loop {
617        let mut grew = false;
618        for (i, unit) in firstparty::UNITS.iter().enumerate() {
619            if reached[i].is_some() {
620                continue;
621            }
622            let injected = reached.iter().flatten().flatten();
623            if !parsed.iter().chain(injected).any(|pf| reaches(pf, unit)) {
624                continue;
625            }
626            let kind = match unit.kind {
627                firstparty::UnitKind::Adapter => UnitKind::Adapter,
628                firstparty::UnitKind::Commons => UnitKind::Commons,
629            };
630            let parse = firstparty_parsed(
631                &CACHES[i],
632                unit.identity_path,
633                unit.source,
634                kind,
635                FIRSTPARTY_ID_BASE + unit.id_block * FIRSTPARTY_ID_BLOCK,
636                firstparty_file_id(unit),
637            );
638            reached[i] = Some(match parse {
639                Ok(pf) => Some(pf),
640                Err(errs) => {
641                    errors.extend_for(None, errs);
642                    None
643                }
644            });
645            grew = true;
646        }
647        if !grew {
648            break;
649        }
650    }
651    let mut order: Vec<usize> = (0..N).filter(|&i| reached[i].is_some()).collect();
652    order.sort_by_key(|&i| firstparty::UNITS[i].id_block);
653    for &i in &order {
654        if let Some(Some(pf)) = reached[i].take() {
655            parsed.push(pf);
656        }
657    }
658    order.into_iter().map(|i| &firstparty::UNITS[i]).collect()
659}
660
661/// S4a (#1966): resolve each derived capability (`capability Sessions = Kv`)
662/// against the capabilities its context flattens in with `consumes U { Kv }`,
663/// and record it. A valid one enters the unit's capability table with its
664/// base's operations, so every later check treats it as an ordinary
665/// capability; its provision is implied (the base's platform provider, bound to
666/// the instance's own Worker binding). Runs after
667/// [`crate::symbols::record_flattened_caps`].
668pub fn phase_resolve_derived(
669    groups: &BTreeMap<String, Vec<usize>>,
670    kinds: &BTreeMap<String, UnitKind>,
671    parsed: &[ParsedFile],
672    unit_tables: &mut HashMap<String, UnitTable>,
673    errors: &mut ErrorSink,
674) {
675    for (name, indices) in groups {
676        for &i in indices {
677            if parsed[i].is_synthetic() {
678                continue;
679            }
680            let path = parsed[i].identity_path();
681            for item in parsed[i].items() {
682                let CommonsItem::Capability(c) = item else {
683                    continue;
684                };
685                let Some(d) = &c.derived else { continue };
686                if kinds.get(name) != Some(&UnitKind::Context) {
687                    errors.push_for(
688                        Some(&path),
689                        CompileError::new(
690                            "bynk.capability.derived_outside_context",
691                            c.span,
692                            format!(
693                                "derived capability `{}` is declared outside a context",
694                                c.name.name
695                            ),
696                        )
697                        .with_note("a named instance of a platform resource belongs to the context that owns its data"),
698                    );
699                    continue;
700                }
701                let Some(table) = unit_tables.get(name) else {
702                    continue;
703                };
704                let Some(owner) = table.flattened_caps.get(&d.base.name).cloned() else {
705                    errors.push_for(
706                        Some(&path),
707                        CompileError::new(
708                            "bynk.capability.unknown_base",
709                            d.base.span,
710                            format!(
711                                "`{}` is not a capability this context selects with `consumes U {{ … }}`",
712                                d.base.name
713                            ),
714                        )
715                        .with_note(format!(
716                            "a derived capability's base is a platform capability, selected by name: `consumes bynk.cloudflare {{ {} }}`",
717                            d.base.name
718                        )),
719                    );
720                    continue;
721                };
722                let Some(kind) = crate::firstparty::instantiable(&owner, &d.base.name) else {
723                    errors.push_for(
724                        Some(&path),
725                        CompileError::new(
726                            "bynk.capability.base_not_instantiable",
727                            d.base.span,
728                            format!(
729                                "`{owner}.{}` has no named instances: a derived capability's base must be a platform resource kind",
730                                d.base.name
731                            ),
732                        ),
733                    );
734                    continue;
735                };
736                // Interop I5 (#1972): each option's value, once valid.
737                let mut options: Vec<(String, crate::symbols::OptionValue)> = Vec::new();
738                for ann in &d.annotations {
739                    if let Some(option_kind) = kind.option_kind(&ann.name.name) {
740                        if options.iter().any(|(o, _)| *o == ann.name.name) {
741                            errors.push_for(
742                                Some(&path),
743                                CompileError::new(
744                                    "bynk.capability.invalid_option",
745                                    ann.span,
746                                    format!("`@{}` is given twice", ann.name.name),
747                                )
748                                .with_note("an instance takes each option once"),
749                            );
750                        } else if let Some(value) =
751                            instance_option_value(ann, option_kind, &d.base.name, &path, errors)
752                        {
753                            // A Worker the build itself makes is a Bynk
754                            // context: deploy and dev read any build Worker
755                            // as a project edge, never a foreign one, so it
756                            // is reached with `consumes` (review of #2008).
757                            let own = match &value {
758                                crate::symbols::OptionValue::Str(value)
759                                    if ann.name.name == firstparty::SERVICE_WORKER_OPTION =>
760                                {
761                                    kinds.iter().find(|(unit, kind)| {
762                                        **kind == UnitKind::Context
763                                            && bynk_project::worker_dir_name(unit) == *value
764                                    })
765                                }
766                                _ => None,
767                            };
768                            if let Some((context, _)) = own {
769                                errors.push_for(
770                                    Some(&path),
771                                    CompileError::new(
772                                        "bynk.capability.invalid_option",
773                                        ann.span,
774                                        format!(
775                                            "`{}` is this project's context `{context}`, not a Worker outside it",
776                                            bynk_project::worker_dir_name(context)
777                                        ),
778                                    )
779                                    .with_note(format!(
780                                        "reach a Bynk context with `consumes {context}`, which checks its contract"
781                                    )),
782                                );
783                            } else {
784                                options.push((ann.name.name.clone(), value));
785                            }
786                        }
787                    }
788                    if kind.option_kind(&ann.name.name).is_none() {
789                        let allowed = if kind.options.is_empty() {
790                            format!("`{}` instances take no options", d.base.name)
791                        } else {
792                            format!(
793                                "`{}` instances take {}",
794                                d.base.name,
795                                kind.option_names()
796                                    .map(|o| format!("`@{o}`"))
797                                    .collect::<Vec<_>>()
798                                    .join(", ")
799                            )
800                        };
801                        errors.push_for(
802                            Some(&path),
803                            CompileError::new(
804                                "bynk.capability.unknown_option",
805                                ann.span,
806                                format!(
807                                    "`@{}` is not an option of a `{}` instance",
808                                    ann.name.name, d.base.name
809                                ),
810                            )
811                            .with_note(allowed),
812                        );
813                    }
814                }
815                for required in kind.required {
816                    if !d.annotations.iter().any(|a| a.name.name == *required) {
817                        errors.push_for(
818                            Some(&path),
819                            CompileError::new(
820                                "bynk.capability.missing_option",
821                                c.span,
822                                format!("a `{}` instance needs `@{required}(…)`", d.base.name),
823                            )
824                            .with_note(format!("write `{}`", instance_example(kind))),
825                        );
826                    }
827                }
828                if table.exported_capabilities.contains(&c.name.name) {
829                    errors.push_for(
830                        Some(&path),
831                        CompileError::new(
832                            "bynk.exports.derived_capability",
833                            c.span,
834                            format!("derived capability `{}` cannot be exported", c.name.name),
835                        )
836                        .with_note("an instance's data belongs to this context; another context reaches it through this context's services"),
837                    );
838                }
839                let ops = unit_tables
840                    .get(&owner)
841                    .and_then(|t| t.capabilities.get(&d.base.name))
842                    .map(|base| base.ops.clone())
843                    .unwrap_or_default();
844                let binding = crate::worker_bindings::instance_binding_name(
845                    kind.binding_prefix,
846                    &c.name.name,
847                );
848                let table = unit_tables.get_mut(name).expect("checked above");
849                // L7 (#2414): Workers Cache is one Worker-wide setting, so a
850                // context declares at most one `EdgeCache` instance.
851                if crate::symbols::is_edge_cache_base(&owner, &d.base.name)
852                    && let Some((earlier, _)) =
853                        table.derived.iter().find(|(_, inst)| inst.is_edge_cache())
854                {
855                    errors.push_for(
856                        Some(&path),
857                        CompileError::new(
858                            "bynk.cloudflare.edge_cache_duplicate",
859                            c.span,
860                            format!(
861                                "context `{name}` already declares `EdgeCache` instance `{earlier}`"
862                            ),
863                        )
864                        .with_note(format!(
865                            "the edge cache is one setting for the whole Worker, so one instance turns it on and purges it; use `{earlier}`"
866                        )),
867                    );
868                    continue;
869                }
870                if let Some(decl) = table.capabilities.get_mut(&c.name.name) {
871                    decl.ops = ops;
872                }
873                table.derived.insert(
874                    c.name.name.clone(),
875                    crate::symbols::DerivedInstance {
876                        base_unit: owner,
877                        base_cap: d.base.name.clone(),
878                        binding,
879                        options,
880                        span: c.span,
881                    },
882                );
883            }
884        }
885    }
886    phase_edge_cache_reach(groups, parsed, unit_tables, errors);
887}
888
889/// L7 (#2414, Decision D): an `EdgeCache` purge reaches the cache only from
890/// the Worker's own `fetch`, so the instance is refused where anything else
891/// runs: an agent handler (a Durable Object,
892/// `bynk.cloudflare.edge_cache_in_agent`), a queue consumer
893/// (`bynk.cloudflare.edge_cache_in_queue`), an `on schedule` handler
894/// (`bynk.cloudflare.edge_cache_in_schedule`: deployed, its purge answers
895/// `success` and clears nothing), and an exported capability, which a consumer
896/// builds in its own Worker (`bynk.cloudflare.edge_cache_exported`).
897/// A handler reaches the instance
898/// through its own `given`, or through a local capability whose provider's
899/// `given` does, at any depth.
900pub fn phase_edge_cache_reach(
901    groups: &BTreeMap<String, Vec<usize>>,
902    parsed: &[ParsedFile],
903    unit_tables: &HashMap<String, UnitTable>,
904    errors: &mut ErrorSink,
905) {
906    for (name, indices) in groups {
907        let Some(table) = unit_tables.get(name) else {
908            continue;
909        };
910        // Each capability that reaches an instance, with the instance it
911        // reaches.
912        let mut reaches: BTreeMap<String, String> = table
913            .derived
914            .iter()
915            .filter(|(_, d)| d.is_edge_cache())
916            .map(|(cap, _)| (cap.clone(), cap.clone()))
917            .collect();
918        if reaches.is_empty() {
919            continue;
920        }
921        loop {
922            let mut grew = false;
923            for (cap, provider) in &table.providers {
924                if reaches.contains_key(cap) {
925                    continue;
926                }
927                let via = provider
928                    .given
929                    .iter()
930                    .filter(|g| !g.is_cross_context())
931                    .find_map(|g| reaches.get(g.key()).cloned());
932                if let Some(instance) = via {
933                    reaches.insert(cap.clone(), instance);
934                    grew = true;
935                }
936            }
937            if !grew {
938                break;
939            }
940        }
941        for &i in indices {
942            let path = parsed[i].identity_path();
943            for item in parsed[i].items() {
944                // A consuming context builds an exported capability's provider
945                // in its own Worker, whose purge would miss this one's cache.
946                if let CommonsItem::Capability(c) = item
947                    && c.derived.is_none()
948                    && table.exported_capabilities.contains(&c.name.name)
949                    && let Some(instance) = reaches.get(&c.name.name)
950                {
951                    errors.push_for(
952                        Some(&path),
953                        CompileError::new(
954                            "bynk.cloudflare.edge_cache_exported",
955                            c.span,
956                            format!(
957                                "exported capability `{}` reaches `EdgeCache` instance `{instance}`, which purges only from this context's own Worker",
958                                c.name.name
959                            ),
960                        )
961                        .with_note(
962                            "a context that consumes it would build its provider in its own Worker, whose purge misses this Worker's cache; export a service that purges instead",
963                        ),
964                    );
965                }
966                // Review of #2417: an opted-in Worker shares a `scope: public`
967                // `2xx` with every later caller. `public` lets the cache store
968                // even a response to a request carrying `Authorization` (RFC
969                // 9111 §3.5, confirmed deployed), so such a route must not
970                // depend on who calls it.
971                if let CommonsItem::Service(svc) = item
972                    && matches!(svc.protocol, ServiceProtocol::Http)
973                {
974                    for h in &svc.handlers {
975                        let public = h.annotations.iter().any(|a| {
976                            a.name.name == "cache"
977                                && a.args.iter().any(|arg| {
978                                    arg.label.as_ref().is_some_and(|l| l.name == "scope")
979                                        && matches!(&arg.value.kind, ExprKind::Ident(id) if id.name == "public")
980                                })
981                        });
982                        let Some(by) = h.by_clause.as_ref().or(svc.default_by.as_ref()) else {
983                            continue;
984                        };
985                        let closed = by.actors.iter().find(|a| {
986                            let single = ByClause {
987                                binder: None,
988                                actors: vec![(*a).clone()],
989                                span: by.span,
990                            };
991                            !crate::actors::sum_has_open_member(&single, &table.actors)
992                        });
993                        if let (true, Some(actor)) = (public, closed) {
994                            errors.push_for(
995                                Some(&path),
996                                CompileError::new(
997                                    "bynk.cloudflare.edge_cache_public_authenticated",
998                                    by.span,
999                                    format!(
1000                                        "a `@cache(scope: public)` route by `{}` would share one caller's response with every caller at Cloudflare's edge",
1001                                        actor.name
1002                                    ),
1003                                )
1004                                .with_note(format!(
1005                                    "context `{name}` declares `EdgeCache`, and the edge cache keys a response by its URL alone; use `scope: private`, or serve the route `by Visitor`"
1006                                )),
1007                            );
1008                        }
1009                    }
1010                }
1011                let (handlers, code, place, note) = match item {
1012                    CommonsItem::Agent(a) => (
1013                        &a.handlers,
1014                        "bynk.cloudflare.edge_cache_in_agent",
1015                        format!("agent `{}`", a.name.name),
1016                        "an agent runs in a Durable Object, whose purge would not reach this Worker's cache; purge from the service handler that calls the agent",
1017                    ),
1018                    CommonsItem::Service(svc)
1019                        if matches!(svc.protocol, ServiceProtocol::Queue { .. }) =>
1020                    {
1021                        (
1022                            &svc.handlers,
1023                            "bynk.cloudflare.edge_cache_in_queue",
1024                            format!("queue consumer `{}`", svc.name.name),
1025                            "a queue consumer is not the entrypoint the cache serves, and its purge is not known to reach that cache; purge from a service handler",
1026                        )
1027                    }
1028                    CommonsItem::Service(svc) if matches!(svc.protocol, ServiceProtocol::Cron) => (
1029                        &svc.handlers,
1030                        "bynk.cloudflare.edge_cache_in_schedule",
1031                        format!("`on schedule` service `{}`", svc.name.name),
1032                        "Cloudflare accepts a purge from a cron run but it clears nothing from the cache the Worker's `fetch` serves; purge from a service handler",
1033                    ),
1034                    _ => continue,
1035                };
1036                for h in handlers {
1037                    for g in h.given.iter().filter(|g| !g.is_cross_context()) {
1038                        let Some(instance) = reaches.get(g.key()) else {
1039                            continue;
1040                        };
1041                        let how = if g.key() == instance {
1042                            String::new()
1043                        } else {
1044                            format!(" (through `{}`)", g.key())
1045                        };
1046                        errors.push_for(
1047                            Some(&path),
1048                            CompileError::new(
1049                                code,
1050                                g.span,
1051                                format!(
1052                                    "{place} reaches `EdgeCache` instance `{instance}`{how}, which purges only from the Worker's `fetch`"
1053                                ),
1054                            )
1055                            .with_note(note),
1056                        );
1057                    }
1058                }
1059            }
1060        }
1061    }
1062}
1063
1064/// The example instance declaration a note about `kind` shows: every
1065/// required option set to a sample value (P3, #2342, so a `RateLimit` note
1066/// shows `@limit(100) @period(60.seconds)`, not a Worker name).
1067pub(crate) fn instance_example(kind: &firstparty::Instantiable) -> String {
1068    let name = match kind.capability {
1069        "Service" => "Billing",
1070        "RateLimit" => "LoginLimit",
1071        "EdgeCache" => "Edge",
1072        _ => "Archives",
1073    };
1074    let mut out = format!("capability {name} = {}", kind.capability);
1075    for required in kind.required {
1076        out.push_str(&format!(" @{required}({})", option_sample(required)));
1077    }
1078    out
1079}
1080
1081/// A sample value of option `option`, as a note writes it.
1082fn option_sample(option: &str) -> &'static str {
1083    match option {
1084        firstparty::R2_JURISDICTION_OPTION => "\"eu\"",
1085        firstparty::RATELIMIT_LIMIT_OPTION => "100",
1086        firstparty::RATELIMIT_PERIOD_OPTION => "60.seconds",
1087        _ => "\"legacy-billing\"",
1088    }
1089}
1090
1091/// Interop I5 (#1972): the value of one instance option: one positional
1092/// literal of the option's kind (P3, #2342). `@worker("name")` names a Worker,
1093/// so it must be a Worker name as Cloudflare takes one: lowercase letters,
1094/// digits, `-` and `_`, at most 63 characters, not starting or ending with
1095/// `-`. `@jurisdiction` is one of R2's; `@limit` a positive `Int`; `@period`
1096/// one of the rate limiting windows. `None`, with the error pushed, when the
1097/// value is not one of these.
1098fn instance_option_value(
1099    ann: &bynk_syntax::ast::Annotation,
1100    kind: firstparty::OptionKind,
1101    base: &str,
1102    path: &std::path::Path,
1103    errors: &mut ErrorSink,
1104) -> Option<crate::symbols::OptionValue> {
1105    use crate::symbols::OptionValue;
1106    let option = ann.name.name.as_str();
1107    let invalid = |span, message: String| {
1108        CompileError::new("bynk.capability.invalid_option", span, message).with_note(format!(
1109            "write `@{option}({})`, for example",
1110            option_sample(option)
1111        ))
1112    };
1113    let [arg] = ann.args.as_slice() else {
1114        errors.push_for(
1115            Some(path),
1116            invalid(
1117                ann.span,
1118                format!("`@{option}` takes one positional argument"),
1119            ),
1120        );
1121        return None;
1122    };
1123    if arg.label.is_some() {
1124        errors.push_for(
1125            Some(path),
1126            invalid(
1127                ann.span,
1128                format!("`@{option}` takes one positional argument"),
1129            ),
1130        );
1131        return None;
1132    }
1133    let value = match (kind, &arg.value.kind) {
1134        (firstparty::OptionKind::Str, ExprKind::StrLit(value)) => OptionValue::Str(value.clone()),
1135        (firstparty::OptionKind::Str, _) => {
1136            errors.push_for(
1137                Some(path),
1138                invalid(arg.span, format!("`@{option}` takes a string literal")),
1139            );
1140            return None;
1141        }
1142        (firstparty::OptionKind::PositiveInt, ExprKind::IntLit { value, .. }) if *value > 0 => {
1143            OptionValue::Int(*value)
1144        }
1145        (firstparty::OptionKind::PositiveInt, _) => {
1146            errors.push_for(
1147                Some(path),
1148                invalid(
1149                    arg.span,
1150                    format!("`@{option}` takes a positive `Int` literal"),
1151                ),
1152            );
1153            return None;
1154        }
1155        (firstparty::OptionKind::Duration, ExprKind::DurationLit { millis, .. })
1156            if *millis % 1000 == 0 && firstparty::RATELIMIT_PERIODS.contains(&(*millis / 1000)) =>
1157        {
1158            OptionValue::Seconds(*millis / 1000)
1159        }
1160        (firstparty::OptionKind::Duration, _) => {
1161            errors.push_for(
1162                Some(path),
1163                CompileError::new(
1164                    "bynk.capability.invalid_option",
1165                    arg.span,
1166                    format!("`@{option}` must be a window a `{base}` offers"),
1167                )
1168                .with_note(format!(
1169                    "the windows are {}",
1170                    firstparty::RATELIMIT_PERIODS
1171                        .iter()
1172                        .map(|s| format!("`{s}.seconds`"))
1173                        .collect::<Vec<_>>()
1174                        .join(" and ")
1175                )),
1176            );
1177            return None;
1178        }
1179    };
1180    let OptionValue::Str(text) = &value else {
1181        return Some(value);
1182    };
1183    if option == crate::firstparty::R2_JURISDICTION_OPTION
1184        && !crate::firstparty::R2_JURISDICTIONS.contains(&text.as_str())
1185    {
1186        errors.push_for(
1187            Some(path),
1188            CompileError::new(
1189                "bynk.capability.invalid_option",
1190                ann.span,
1191                format!("`{text}` is not a jurisdiction a `{base}` bucket can be pinned to"),
1192            )
1193            .with_note(format!(
1194                "the jurisdictions are {}",
1195                crate::firstparty::R2_JURISDICTIONS
1196                    .iter()
1197                    .map(|j| format!("`\"{j}\"`"))
1198                    .collect::<Vec<_>>()
1199                    .join(" and ")
1200            )),
1201        );
1202        return None;
1203    }
1204    if option == crate::firstparty::SERVICE_WORKER_OPTION && !is_worker_name(text) {
1205        errors.push_for(
1206            Some(path),
1207            CompileError::new(
1208                "bynk.capability.invalid_option",
1209                ann.span,
1210                format!("`{text}` is not a Worker name a `{base}` can reach"),
1211            )
1212            .with_note(
1213                "a Worker name is 1 to 63 lowercase letters, digits, `-` or `_`, and does not start or end with `-`",
1214            ),
1215        );
1216        return None;
1217    }
1218    Some(value)
1219}
1220
1221/// Whether `name` is a Cloudflare Worker name ([`instance_option_value`]).
1222fn is_worker_name(name: &str) -> bool {
1223    (1..=63).contains(&name.len())
1224        && name
1225            .bytes()
1226            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
1227        && !name.starts_with('-')
1228        && !name.ends_with('-')
1229}
1230
1231/// #1710: for each unit with a file the strict parse rejected, the names of
1232/// the declarations recovery skipped there. References to them, from any file
1233/// that can see the unit, are known names, not unknown ones (#1663's Decision
1234/// B); see [`crate::check_pipeline::prepare_unit_check_ctx`].
1235pub type BrokenDeclNames = HashMap<String, Vec<String>>;
1236
1237/// The `include` tree that discovered `pf`, found by matching its absolute
1238/// path against each tree's root — not `trees[0]` unconditionally, since
1239/// R3.9 (#1113) lets a file live under any `include` tree, not just the
1240/// first. Falls back to `trees[0]` for a `pf` with no `abs_path` (unreachable
1241/// for a real adapter: only synthetic units, which never declare `binding`,
1242/// go without one) or if it somehow matches none. The longest matching root
1243/// wins, in case one `include` tree is nested inside another.
1244///
1245/// A `trees` root is only absolute when `Roots`'s own `project_root` is — a
1246/// relative project root (`bynkc build .`, the ordinary CLI shape) leaves
1247/// every tree root relative, while `pf.abs_path()` (`bynk-project`'s
1248/// `parse_sources`, via `std::path::absolute`) is always absolute. Comparing
1249/// them directly with `starts_with` would never match, silently collapsing
1250/// this back to the `trees[0]` bug it exists to fix. Each root is resolved
1251/// through the same `std::path::absolute` before comparing, matching
1252/// `abs_path`'s own normalisation exactly rather than requiring the caller
1253/// to have already absolutised `Roots::project_root`.
1254pub fn tree_root_for<'a>(trees: &'a [(PathBuf, PathBuf)], pf: &ParsedFile) -> &'a Path {
1255    let Some(abs) = pf.abs_path() else {
1256        return trees[0].0.as_path();
1257    };
1258    trees
1259        .iter()
1260        .filter_map(|(root, _)| {
1261            std::path::absolute(root)
1262                .ok()
1263                .map(|abs_root| (root, abs_root))
1264        })
1265        .filter(|(_, abs_root)| abs.starts_with(abs_root))
1266        .max_by_key(|(root, _)| root.as_os_str().len())
1267        .map(|(root, _)| root.as_path())
1268        .unwrap_or_else(|| trees[0].0.as_path())
1269}
1270
1271/// #2321: resolve an adapter binding's `includes [ … ]` list. Each path is
1272/// relative to the adapter's own directory, must stay inside it (no absolute
1273/// path, no `..`), must be a `.wasm`, and must be unique once normalised; the
1274/// file is read beside the binding (a dependency's from beside the file it was
1275/// read from, like the binding itself, #2223). Reports the four
1276/// `bynk.adapter.include_*` diagnostics and skips the offending entry.
1277fn resolve_binding_includes(
1278    pf: &ParsedFile,
1279    a: &AdapterDecl,
1280    b: &BindingDecl,
1281    adapter_dir: &Path,
1282    adapter_src_dir: &Path,
1283    errors: &mut ErrorSink,
1284) -> Vec<IncludedFile> {
1285    let adapter = a.name.joined();
1286    let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
1287    let mut out = Vec::new();
1288    for inc in &b.includes {
1289        let raw = Path::new(&inc.path);
1290        let escapes = raw.is_absolute()
1291            || raw.components().any(|c| {
1292                matches!(
1293                    c,
1294                    std::path::Component::ParentDir
1295                        | std::path::Component::RootDir
1296                        | std::path::Component::Prefix(_)
1297                )
1298            });
1299        if escapes {
1300            errors.push_for(
1301                Some(&pf.identity_path()),
1302                CompileError::new(
1303                    "bynk.adapter.include_outside",
1304                    inc.span,
1305                    format!(
1306                        "adapter `{adapter}` includes `{}`, which is outside the adapter's directory",
1307                        inc.path
1308                    ),
1309                )
1310                .with_note("an included path is relative to the adapter's file and may not be absolute or contain `..`"),
1311            );
1312            continue;
1313        }
1314        let rel = normalize_rel(raw);
1315        if rel.extension().and_then(|e| e.to_str()) != Some("wasm") {
1316            errors.push_for(
1317                Some(&pf.identity_path()),
1318                CompileError::new(
1319                    "bynk.adapter.include_kind",
1320                    inc.span,
1321                    format!(
1322                        "adapter `{adapter}` includes `{}`, which is not a `.wasm` file",
1323                        inc.path
1324                    ),
1325                )
1326                .with_note("only WebAssembly modules (`.wasm`) can be included beside a binding"),
1327            );
1328            continue;
1329        }
1330        if !seen.insert(rel.clone()) {
1331            errors.push_for(
1332                Some(&pf.identity_path()),
1333                CompileError::new(
1334                    "bynk.adapter.include_duplicate",
1335                    inc.span,
1336                    format!("adapter `{adapter}` includes `{}` more than once", inc.path),
1337                )
1338                .with_note("remove the repeated entry"),
1339            );
1340            continue;
1341        }
1342        match read_adapter_include(&adapter_src_dir.join(&rel)) {
1343            Ok(bytes) => out.push(IncludedFile {
1344                output_path: normalize_rel(&adapter_dir.join(&rel)),
1345                bytes,
1346            }),
1347            Err(e) => errors.push_for(
1348                Some(&pf.identity_path()),
1349                CompileError::new(
1350                    "bynk.adapter.include_missing",
1351                    inc.span,
1352                    format!(
1353                        "adapter `{adapter}` includes `{}`, which could not be read ({e})",
1354                        inc.path
1355                    ),
1356                )
1357                .with_note("the path is resolved relative to the adapter's source file"),
1358            ),
1359        }
1360    }
1361    out
1362}
1363
1364/// Phase 3: group the parsed units by qualified name (production units, unit
1365/// tests, and integration suites tracked separately), run the per-directory
1366/// and path/name consistency checks, enforce the reserved `bynk` namespace and
1367/// the adapter `binding` rules, resolve each adapter's binding module, and fold
1368/// the adapters' pinned npm dependencies. Pushes diagnostics into `errors` and
1369/// returns the production `groups`/`kinds`, the `test`/`integration` groups, the
1370/// resolved `adapter_bindings`, and the collected `npm_deps`.
1371#[allow(clippy::type_complexity)]
1372#[allow(clippy::too_many_arguments)]
1373pub fn phase_group(
1374    parsed: &[ParsedFile],
1375    trees: &[(PathBuf, PathBuf)],
1376    platform: Platform,
1377    injected: &[&'static firstparty::Unit],
1378    overlay: &HashMap<PathBuf, String>,
1379    errors: &mut ErrorSink,
1380) -> (
1381    BTreeMap<String, Vec<usize>>,
1382    BTreeMap<String, UnitKind>,
1383    BTreeMap<String, Vec<usize>>,
1384    BTreeMap<String, Vec<usize>>,
1385    HashMap<String, AdapterBinding>,
1386    std::collections::BTreeMap<String, String>,
1387) {
1388    // Tests (v0.7) are tracked separately from production units. Their
1389    // `target` joined-name can intentionally coincide with a commons or
1390    // context name; they don't enter the production groups/kinds maps.
1391    let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1392    let mut kinds: BTreeMap<String, UnitKind> = BTreeMap::new();
1393    let mut test_groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1394    // v0.16: integration tests are tracked by suite name, separately again from
1395    // unit tests — their `name()` is the synthetic `integration <suite>`.
1396    let mut integration_groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1397    for (i, pf) in parsed.iter().enumerate() {
1398        let name = pf.unit().name().joined();
1399        if pf.kind() == UnitKind::Integration {
1400            integration_groups.entry(name).or_default().push(i);
1401        } else if pf.kind() == UnitKind::Test {
1402            test_groups.entry(name).or_default().push(i);
1403        } else {
1404            groups.entry(name.clone()).or_default().push(i);
1405            kinds.entry(name).or_insert(pf.kind());
1406        }
1407    }
1408    // #696: the consistency checks pair each error with the project-relative
1409    // path of the file its primary span belongs to, so the CLI renders them
1410    // with ariadne source context rather than the plain fallback.
1411    if let Err(e) = check_directory_name_consistency(parsed) {
1412        for (path, err) in e {
1413            errors.push_for(Some(&path), err);
1414        }
1415    }
1416    if let Err(e) = check_directory_kind_consistency(parsed) {
1417        errors.extend_for(None, e);
1418    }
1419    // A group must agree on kind across all its files (different name but
1420    // same kind is fine; same name but different kind is an error).
1421    if let Err(e) = check_group_kind_consistency(parsed, &groups) {
1422        for (path, err) in e {
1423            errors.push_for(Some(&path), err);
1424        }
1425    }
1426    // #1885 (DECISION D): a unit split across files carries at most one
1427    // module doc.
1428    if let Err(e) = check_group_module_docs(parsed, &groups) {
1429        for (path, err) in e {
1430            errors.push_for(Some(&path), err);
1431        }
1432    }
1433    // Each *source* unit's file path must match its declared qualified name.
1434    // v0.113 (DECISION S): a `suite` has no path-identity requirement — it names
1435    // its target and is legal in any file — so test-ness carries no path check.
1436    if let Err(e) = check_path_name_alignment(parsed) {
1437        for (path, err) in e {
1438            errors.push_for(Some(&path), err);
1439        }
1440    }
1441
1442    // v0.20a: function types are confined to non-boundary positions. P5.2:
1443    // this used to be an injected hook (`function_type_boundary_check`) so
1444    // `run_checks` and the new analysis entry point could reach it without
1445    // `bynk-check` reaching back into `bynk-emit`; now that the check lives
1446    // here too, it's a direct call at the exact point the hook used to fire,
1447    // preserving diagnostic order for both callers with no hook needed.
1448    phase_function_type_boundaries(parsed, errors);
1449
1450    // v0.17: the `bynk` root namespace is reserved for the toolchain. No user
1451    // unit of any kind may be named `bynk` or `bynk.*` (§3.4).
1452    for pf in parsed {
1453        if pf.is_synthetic() {
1454            continue;
1455        }
1456        let qn = pf.unit().name();
1457        if qn.parts.first().is_some_and(|p| p.name == "bynk") {
1458            errors.push_for(Some(&pf.identity_path()),
1459                CompileError::new(
1460                    "bynk.namespace.reserved",
1461                    qn.span,
1462                    format!(
1463                        "`{}` uses the reserved `bynk` namespace — the `bynk` root is reserved for the toolchain's conformance surface",
1464                        qn.joined()
1465                    ),
1466                )
1467                .with_note("rename the unit so its first segment is not `bynk`"),
1468            );
1469        }
1470    }
1471
1472    // v0.17: an adapter that declares any external provider must name a
1473    // `binding` module to supply the implementation symbols (§3.5). First-party
1474    // (synthetic) adapters omit the clause — the toolchain supplies the binding.
1475    for pf in parsed {
1476        if pf.is_synthetic() {
1477            continue;
1478        }
1479        if let Some(a) = pf.adapter() {
1480            let has_external = a
1481                .items
1482                .iter()
1483                .any(|it| matches!(it, CommonsItem::Provider(p) if p.external));
1484            if has_external && a.binding.is_none() {
1485                errors.push_for(Some(&pf.identity_path()),
1486                    CompileError::new(
1487                        "bynk.adapter.no_binding",
1488                        a.span,
1489                        format!(
1490                            "adapter `{}` declares an external provider but has no `binding` clause to supply its implementation",
1491                            a.name.joined()
1492                        ),
1493                    )
1494                    .with_note(
1495                        "add a `binding \"<module>\"` clause naming the TypeScript module that exports the provider symbols",
1496                    ),
1497                );
1498            }
1499        }
1500    }
1501
1502    // v0.17: resolve each adapter's binding module (relative to the adapter's
1503    // source file) and read it, so compose can import the external provider
1504    // symbols and the binding is copied into the output for the `tsc` gate.
1505    let mut adapter_bindings: HashMap<String, AdapterBinding> = HashMap::new();
1506    // v0.17/v0.19: the toolchain supplies each injected first-party adapter's
1507    // binding — the `bynk` surface's per platform, a platform adapter's single
1508    // one (the lock check rejects other `--platform` selections).
1509    for unit in injected {
1510        if let Some((output_path, content)) = unit.binding_for(platform) {
1511            adapter_bindings.insert(
1512                unit.name.to_string(),
1513                AdapterBinding {
1514                    output_path: PathBuf::from(output_path),
1515                    content: content.to_string(),
1516                    includes: Vec::new(),
1517                },
1518            );
1519        }
1520    }
1521    for pf in parsed {
1522        let Some(a) = pf.adapter() else { continue };
1523        let Some(b) = &a.binding else { continue };
1524        let pf_source_path = pf.source_path();
1525        let adapter_dir = pf_source_path.parent().unwrap_or(Path::new(""));
1526        let out_rel = normalize_rel(&adapter_dir.join(&b.module));
1527        // #2223: a dependency file's `source_path` is its output layout
1528        // (`deps/<org>/<package>/…`), so its binding is found beside the file
1529        // it was read from.
1530        let src_abs = match (pf.package(), pf.abs_path()) {
1531            (Some(_), Some(abs)) => {
1532                let dir = abs.parent().unwrap_or(Path::new("")).to_path_buf();
1533                dir.join(normalize_rel(Path::new(&b.module)))
1534            }
1535            _ => tree_root_for(trees, pf).join(&out_rel),
1536        };
1537        match read_adapter_binding(&src_abs, overlay) {
1538            Ok(content) => {
1539                // The adapter file's own directory on disk — what includes are
1540                // relative to (the binding may sit in a subdirectory of it).
1541                let adapter_src_dir = match (pf.package(), pf.abs_path()) {
1542                    (Some(_), Some(abs)) => abs.parent().unwrap_or(Path::new("")).to_path_buf(),
1543                    _ => tree_root_for(trees, pf).join(adapter_dir),
1544                };
1545                let includes =
1546                    resolve_binding_includes(pf, a, b, adapter_dir, &adapter_src_dir, errors);
1547                adapter_bindings.insert(
1548                    a.name.joined(),
1549                    AdapterBinding {
1550                        output_path: out_rel,
1551                        content,
1552                        includes,
1553                    },
1554                );
1555            }
1556            Err(e) => {
1557                errors.push_for(Some(&pf.identity_path()),
1558                    CompileError::new(
1559                        "bynk.adapter.no_binding",
1560                        b.module_span,
1561                        format!(
1562                            "adapter `{}` names binding module `{}`, which could not be read ({e})",
1563                            a.name.joined(),
1564                            b.module
1565                        ),
1566                    )
1567                    .with_note(
1568                        "the binding path is resolved relative to the adapter's source file; author the `.binding.ts` there",
1569                    ),
1570                );
1571            }
1572        }
1573    }
1574
1575    // v0.17: collect adapter npm dependencies for `package.json`, rejecting
1576    // unpinned ranges ([DECISION L] stub — fold + pin-check only, no allow-list).
1577    // #2201: two entries for one package must agree on the range; a different
1578    // range is refused rather than intersected or overwritten.
1579    let mut npm_deps: std::collections::BTreeMap<String, String> =
1580        std::collections::BTreeMap::new();
1581    // package → (adapter, its file's identity path, entry span) of the first entry.
1582    let mut first_dep: HashMap<String, (String, PathBuf, Span)> = HashMap::new();
1583    for pf in parsed {
1584        let Some(a) = pf.adapter() else { continue };
1585        let Some(b) = &a.binding else { continue };
1586        for dep in &b.requires {
1587            if is_unpinned_range(&dep.range) {
1588                errors.push_for(Some(&pf.identity_path()),
1589                    CompileError::new(
1590                        "bynk.requires.unpinned_dependency",
1591                        dep.span,
1592                        format!(
1593                            "dependency `{}` has an unpinned version range `{}` — pin a concrete range (e.g. `^1.2.0`)",
1594                            dep.package, dep.range
1595                        ),
1596                    )
1597                    .with_note(
1598                        "unpinned ranges (`*`, `latest`, …) make builds irreproducible and are rejected",
1599                    ),
1600                );
1601                continue;
1602            }
1603            let adapter = a.name.joined();
1604            let identity = pf.identity_path();
1605            if let Some(range) = npm_deps.get(&dep.package) {
1606                if *range != dep.range {
1607                    let (first_adapter, first_path, first_span) = &first_dep[&dep.package];
1608                    let message = if *first_adapter == adapter {
1609                        format!(
1610                            "adapter `{adapter}` requires `{}` at both `{range}` and `{}`",
1611                            dep.package, dep.range
1612                        )
1613                    } else {
1614                        format!(
1615                            "adapter `{adapter}` requires `{}` at `{}`, but adapter `{first_adapter}` requires it at `{range}`",
1616                            dep.package, dep.range
1617                        )
1618                    };
1619                    let mut err =
1620                        CompileError::new("bynk.requires.conflicting_range", dep.span, message);
1621                    if *first_path == identity {
1622                        err = err.with_label(*first_span, "first required here");
1623                    }
1624                    errors.push_for(
1625                        Some(&identity),
1626                        err.with_note(format!(
1627                            "`{}` is first required at `{range}` by adapter `{first_adapter}` (in `{}`); the project installs one version of each package, so every adapter must state the same range",
1628                            dep.package,
1629                            first_path
1630                                .components()
1631                                .map(|c| c.as_os_str().to_string_lossy())
1632                                .collect::<Vec<_>>()
1633                                .join("/")
1634                        )),
1635                    );
1636                }
1637                continue;
1638            }
1639            first_dep.insert(dep.package.clone(), (adapter, identity, dep.span));
1640            npm_deps.insert(dep.package.clone(), dep.range.clone());
1641        }
1642    }
1643
1644    (
1645        groups,
1646        kinds,
1647        test_groups,
1648        integration_groups,
1649        adapter_bindings,
1650        npm_deps,
1651    )
1652}
1653
1654/// v0.20a: apply the function-type boundary confinement to every serialisable
1655/// or boundary-crossing position in a file's items: record fields and sum
1656/// payloads (types can cross contexts and persist), service/agent handler
1657/// signatures (the Workers wire), capability operation signatures (kept out
1658/// in v0.20a — see ADR 0030), agent state fields, and agent keys. Free `fn`
1659/// signatures are deliberately NOT walked — they are the non-boundary home
1660/// of function types.
1661///
1662/// #696: each diagnostic is paired with the project-relative `identity_path` of
1663/// the file whose items produced it, so the CLI renders it against that file's
1664/// source.
1665///
1666/// P5.2 (`design/tracks/semantics-in-the-checker.md` §6): relocated verbatim
1667/// from `bynk-emit/src/project/validate.rs`'s `check_function_type_boundaries`
1668/// — category 6 of `analysis.rs`'s own seven-category accounting. Previously
1669/// reached only through `phase_group`'s optional `function_type_boundary_check`
1670/// hook (`Some` from `run_checks`, `None` from the new entry point); that hook
1671/// is gone — [`phase_group`] itself now calls this function directly, at the
1672/// exact point the hook used to fire, so both callers see it in the same
1673/// diagnostic-ordering position as before and can no longer drift on whether
1674/// the check runs at all.
1675pub fn phase_function_type_boundaries(parsed: &[ParsedFile], errors: &mut ErrorSink) {
1676    // v0.174 (#592): the boundary check now also rejects a *recursive* generic
1677    // record (`reject_fn_types`' `App` arm), which needs the type declarations to
1678    // walk the containment graph. Build the project-wide table once — a generic
1679    // referenced from one file may be declared in another.
1680    let types = collect_type_decls(parsed.iter().flat_map(|pf| pf.items()));
1681    for pf in parsed {
1682        let mut file_errors: Vec<CompileError> = Vec::new();
1683        check_function_type_boundary_items(pf.items(), &types, &mut file_errors);
1684        for err in file_errors {
1685            errors.push_for(Some(&pf.identity_path()), err);
1686        }
1687    }
1688}
1689
1690/// v0.174 (#592): a `name -> TypeDecl` table over a set of items, for the
1691/// recursive-generic boundary walk. Relocated alongside
1692/// `phase_function_type_boundaries` (P5.2) — public since `bynk-emit`'s
1693/// single-file compile path (`lib.rs`) also needs it, across the crate
1694/// boundary this relocation now draws.
1695pub fn collect_type_decls<'a>(
1696    items: impl Iterator<Item = &'a CommonsItem>,
1697) -> HashMap<String, Arc<TypeDecl>> {
1698    let mut out = HashMap::new();
1699    for item in items {
1700        match item {
1701            CommonsItem::Type(t) => {
1702                out.entry(t.name.name.clone())
1703                    .or_insert_with(|| Arc::new(t.clone()));
1704            }
1705            // Events track, slice 0 (spine #936): an event's synthetic
1706            // `TypeDecl` joins the same table, so a field referencing an
1707            // event type recurses into it exactly like any other type.
1708            CommonsItem::Event(e) => {
1709                out.entry(e.name.name.clone())
1710                    .or_insert_with(|| Arc::new(e.as_type_decl()));
1711            }
1712            _ => {}
1713        }
1714    }
1715    out
1716}
1717
1718/// Item-level body of the boundary confinement, shared with the single-file
1719/// (legacy) compile path in `bynk-emit`'s `lib.rs`. Relocated alongside
1720/// `phase_function_type_boundaries` (P5.2).
1721pub fn check_function_type_boundary_items(
1722    items: &[CommonsItem],
1723    types: &HashMap<String, Arc<TypeDecl>>,
1724    errors: &mut Vec<CompileError>,
1725) {
1726    for item in items {
1727        match item {
1728            CommonsItem::Type(t) => match &t.body {
1729                TypeBody::Record(r) => {
1730                    for f in &r.fields {
1731                        reject_fn_types(&f.type_ref, "a record field", types, errors);
1732                    }
1733                }
1734                TypeBody::Sum(s) => {
1735                    for v in &s.variants {
1736                        for p in &v.payload {
1737                            reject_fn_types(&p.type_ref, "a sum-variant payload", types, errors);
1738                        }
1739                    }
1740                }
1741                TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
1742            },
1743            // Events track, slice 0 (spine #936): an event's fields are
1744            // boundary values (an emission crosses a context boundary),
1745            // so the same record-field rule applies as for a `type`.
1746            CommonsItem::Event(e) => {
1747                for f in &e.body.fields {
1748                    reject_fn_types(&f.type_ref, "an event field", types, errors);
1749                }
1750            }
1751            CommonsItem::Capability(c) => {
1752                for op in &c.ops {
1753                    for p in &op.params {
1754                        reject_fn_types(
1755                            &p.type_ref,
1756                            "a capability operation signature",
1757                            types,
1758                            errors,
1759                        );
1760                    }
1761                    // v0.102 (§2.9.1): a capability operation may *produce* a
1762                    // held value — it is the canonical held source — so an
1763                    // `Effect[Connection[F]]` return is admitted.
1764                    if !type_ref_is_held(&op.return_type) {
1765                        reject_fn_types(
1766                            &op.return_type,
1767                            "a capability operation signature",
1768                            types,
1769                            errors,
1770                        );
1771                    }
1772                }
1773            }
1774            CommonsItem::Service(s) => {
1775                for h in &s.handlers {
1776                    for p in &h.params {
1777                        // #2341 (decision A): an HTTP handler's `headers` is
1778                        // the one place `RequestHeaders` may appear.
1779                        let http_headers = matches!(h.kind, HandlerKind::Http { .. })
1780                            && p.name.name == "headers"
1781                            && matches!(p.type_ref, TypeRef::RequestHeaders(_));
1782                        // #2341 (decision C): and `on rejected`'s parameter is
1783                        // the one place `BoundaryRejection` may appear.
1784                        let http_headers = http_headers
1785                            || (h.kind == HandlerKind::Rejected
1786                                && matches!(p.type_ref, TypeRef::BoundaryRejection(_)));
1787                        // v0.102 (§2.9.4): the framework may supply a held
1788                        // value as a handler parameter (the `on open`
1789                        // connection), so a `Connection[F]` parameter is
1790                        // admitted.
1791                        if !type_ref_is_held(&p.type_ref) && !http_headers {
1792                            reject_fn_types(
1793                                &p.type_ref,
1794                                "a service handler signature",
1795                                types,
1796                                errors,
1797                            );
1798                        }
1799                    }
1800                    reject_fn_types(&h.return_type, "a service handler signature", types, errors);
1801                }
1802            }
1803            CommonsItem::Agent(a) => {
1804                reject_fn_types(&a.key_type, "an agent key", types, errors);
1805                for f in &a.store_fields {
1806                    validate_store_field_value_types(f, types, errors);
1807                }
1808                for h in &a.handlers {
1809                    for p in &h.params {
1810                        // v0.102 (§2.9.4): a held value may be transferred to
1811                        // an agent handler as a parameter.
1812                        if !type_ref_is_held(&p.type_ref) {
1813                            reject_fn_types(
1814                                &p.type_ref,
1815                                "an agent handler signature",
1816                                types,
1817                                errors,
1818                            );
1819                        }
1820                    }
1821                    reject_fn_types(&h.return_type, "an agent handler signature", types, errors);
1822                }
1823            }
1824            CommonsItem::Actor(a) => {
1825                if let Some(id) = &a.identity {
1826                    reject_fn_types(id, "an actor identity type", types, errors);
1827                }
1828            }
1829            // slice 1: `MessageEntry.code`/`.template` are plain string
1830            // literals, no fn-type-bearing fields to reject here.
1831            CommonsItem::Fn(_)
1832            | CommonsItem::Provider(_)
1833            | CommonsItem::Messages(_)
1834            | CommonsItem::Asset(_)
1835            | CommonsItem::Embed(_) => {}
1836        }
1837    }
1838}
1839
1840/// Phase 4: build each production unit's combined symbol table from its files,
1841/// pushing any table-construction errors into `errors`.
1842pub fn phase_symbol_tables(
1843    groups: &BTreeMap<String, Vec<usize>>,
1844    kinds: &BTreeMap<String, UnitKind>,
1845    parsed: &[ParsedFile],
1846    errors: &mut ErrorSink,
1847) -> HashMap<String, UnitTable> {
1848    let mut unit_tables: HashMap<String, UnitTable> = HashMap::new();
1849    for (name, indices) in groups {
1850        let kind = *kinds.get(name).expect("every group has a kind");
1851        // #696: build_unit_table pairs each diagnostic with its declaring file.
1852        let mut table_errors: Vec<(PathBuf, CompileError)> = Vec::new();
1853        let table = build_unit_table(name, kind, indices, parsed, &mut table_errors);
1854        for (path, err) in table_errors {
1855            errors.push_for(Some(&path), err);
1856        }
1857        unit_tables.insert(name.clone(), table);
1858    }
1859    unit_tables
1860}
1861
1862/// Phase 5: resolve each unit's `uses` clauses, checking the target exists, is
1863/// a commons, and is not self-referential. Returns unit → deduplicated list of
1864/// used commons; diagnostics go into `errors`.
1865/// #1702 review: the order to check units in — every `uses` target before the
1866/// units that use it, ties broken by name, so the order stays a function of
1867/// the source alone (`deterministic_diagnostic_order_behaviour`). A generic
1868/// function's compared type parameters (#1688) are computed while its own unit
1869/// is checked, in that unit's environment, so an importer must come after.
1870/// Units on a `uses` cycle (already an error) are appended in name order.
1871pub fn uses_first_order<'a, I>(
1872    names: I,
1873    unit_uses: &HashMap<String, Vec<String>>,
1874) -> Vec<&'a String>
1875where
1876    I: IntoIterator<Item = &'a String>,
1877{
1878    let names: BTreeSet<&String> = names.into_iter().collect();
1879    let mut placed: HashSet<&String> = HashSet::new();
1880    let mut order = Vec::with_capacity(names.len());
1881    loop {
1882        let ready: Vec<&String> = names
1883            .iter()
1884            .copied()
1885            .filter(|n| !placed.contains(n))
1886            .filter(|n| {
1887                unit_uses.get(*n).is_none_or(|deps| {
1888                    deps.iter()
1889                        .all(|d| !names.contains(d) || placed.iter().any(|p| *p == d))
1890                })
1891            })
1892            .collect();
1893        if ready.is_empty() {
1894            break;
1895        }
1896        for n in ready {
1897            placed.insert(n);
1898            order.push(n);
1899        }
1900    }
1901    order.extend(names.iter().copied().filter(|n| !placed.contains(n)));
1902    order
1903}
1904
1905pub fn phase_resolve_uses(
1906    groups: &BTreeMap<String, Vec<usize>>,
1907    kinds: &BTreeMap<String, UnitKind>,
1908    parsed: &[ParsedFile],
1909    unit_tables: &HashMap<String, UnitTable>,
1910    // S3 (#1963): `uses` of an experimental commons needs an `[unstable]` opt-in.
1911    policy: &crate::stability::StabilityPolicy,
1912    errors: &mut ErrorSink,
1913) -> HashMap<String, Vec<String>> {
1914    let mut unit_uses: HashMap<String, Vec<String>> = HashMap::new();
1915    for (name, indices) in groups {
1916        let mut uses_targets: Vec<String> = Vec::new();
1917        for &i in indices {
1918            for u in parsed[i].uses() {
1919                let target = u.target.joined();
1920                if !unit_tables.contains_key(&target) {
1921                    errors.push_for(
1922                        Some(&parsed[i].identity_path()),
1923                        CompileError::new(
1924                            "bynk.uses.unknown_commons",
1925                            u.span,
1926                            format!("unknown commons `{target}`"),
1927                        )
1928                        .with_note(
1929                            "the target of a `uses` clause must be a commons in the project",
1930                        ),
1931                    );
1932                    continue;
1933                }
1934                // A first-party unit's own dependencies are the library's
1935                // business, never the project's opt-in.
1936                if !parsed[i].is_synthetic()
1937                    && let Some(err) = policy.check(&target, u.target.span)
1938                {
1939                    errors.push_for(Some(&parsed[i].identity_path()), err);
1940                }
1941                let target_kind = *kinds.get(&target).unwrap();
1942                if target_kind != UnitKind::Commons {
1943                    errors.push_for(Some(&parsed[i].identity_path()),
1944                        CompileError::new(
1945                            "bynk.uses.target_is_context",
1946                            u.span,
1947                            format!(
1948                                "`uses {target}` targets a context — `uses` may only target a commons"
1949                            ),
1950                        )
1951                        .with_note(
1952                            "to declare a dependency on a context, use `consumes` instead",
1953                        ),
1954                    );
1955                    continue;
1956                }
1957                if target == *name {
1958                    errors.push_for(
1959                        Some(&parsed[i].identity_path()),
1960                        CompileError::new(
1961                            "bynk.uses.self_reference",
1962                            u.span,
1963                            format!("`{name}` cannot `uses` itself"),
1964                        ),
1965                    );
1966                    continue;
1967                }
1968                if !uses_targets.contains(&target) {
1969                    uses_targets.push(target);
1970                }
1971            }
1972        }
1973        unit_uses.insert(name.clone(), uses_targets);
1974    }
1975    unit_uses
1976}
1977
1978/// Phase 5b: resolve each unit's `consumes` clauses (target exists, is a context
1979/// or adapter, not self-referential, obeys the adapter selection rules), and for
1980/// the braced `consumes U { Cap, … }` form validate and record the flattened
1981/// capabilities. Returns unit → consumed targets and unit → flattened-cap → owning
1982/// unit; diagnostics go into `errors` and clause-position references into `refs`.
1983#[allow(clippy::type_complexity)]
1984pub fn phase_resolve_consumes(
1985    groups: &BTreeMap<String, Vec<usize>>,
1986    kinds: &BTreeMap<String, UnitKind>,
1987    parsed: &[ParsedFile],
1988    unit_tables: &HashMap<String, UnitTable>,
1989    // S3 (#1963): consuming an experimental adapter, or selecting an
1990    // experimental capability, needs an `[unstable]` opt-in.
1991    policy: &crate::stability::StabilityPolicy,
1992    errors: &mut ErrorSink,
1993    refs: &mut RefSink,
1994) -> (
1995    HashMap<String, Vec<String>>,
1996    HashMap<String, HashMap<String, String>>,
1997) {
1998    let mut unit_consumes: HashMap<String, Vec<String>> = HashMap::new();
1999    // v0.17: `consumes U { Cap, … }` flattens selected caps into the consumer's
2000    // local namespace. unit → bare-cap → consumed unit providing it.
2001    let mut unit_flattened: HashMap<String, HashMap<String, String>> = HashMap::new();
2002    for (name, indices) in groups {
2003        let kind = *kinds.get(name).unwrap();
2004        let mut consumes_targets: Vec<String> = Vec::new();
2005        let mut flattened: HashMap<String, String> = HashMap::new();
2006        // #1857: the file that first selected each flattened capability, so a
2007        // file of a split context may repeat another file's selection.
2008        let mut flattened_in: HashMap<String, usize> = HashMap::new();
2009        let local_caps: HashSet<String> = unit_tables
2010            .get(name)
2011            .map(|t| t.capabilities.keys().cloned().collect())
2012            .unwrap_or_default();
2013        for &i in indices {
2014            refs.enter_file(&parsed[i].identity_path(), name, parsed[i].is_synthetic());
2015            for c in parsed[i].consumes() {
2016                let target = c.target.joined();
2017                // v0.18: an adapter's `consumes` is the braced capability-selection
2018                // form only — an adapter has no services to RPC-call, so the
2019                // whole-unit and `as Alias` forms are meaningless inside one.
2020                if kind == UnitKind::Adapter && c.selected.is_none() {
2021                    errors.push_for(Some(&parsed[i].identity_path()),
2022                        CompileError::new(
2023                            "bynk.adapter.consumes_requires_selection",
2024                            c.span,
2025                            format!(
2026                                "an adapter's `consumes` must select capabilities — write `consumes {target} {{ Cap, … }}`",
2027                            ),
2028                        )
2029                        .with_note(
2030                            "adapters depend on capabilities, never on services; the whole-unit and aliased forms are context-only",
2031                        ),
2032                    );
2033                    continue;
2034                }
2035                if !unit_tables.contains_key(&target) {
2036                    errors.push_for(
2037                        Some(&parsed[i].identity_path()),
2038                        CompileError::new(
2039                            "bynk.consumes.unknown_context",
2040                            c.span,
2041                            format!("unknown context `{target}`"),
2042                        )
2043                        .with_note(
2044                            "the target of a `consumes` clause must be a context in the project",
2045                        ),
2046                    );
2047                    continue;
2048                }
2049                let target_kind = *kinds.get(&target).unwrap();
2050                // v0.17: `consumes` may target a context or an adapter (the host
2051                // boundary). It may not target a commons (use `uses` for that).
2052                if target_kind != UnitKind::Context && target_kind != UnitKind::Adapter {
2053                    errors.push_for(Some(&parsed[i].identity_path()),
2054                        CompileError::new(
2055                            "bynk.consumes.target_is_commons",
2056                            c.span,
2057                            format!(
2058                                "`consumes {target}` targets a commons — `consumes` may only target a context or adapter"
2059                            ),
2060                        )
2061                        .with_note(
2062                            "to mix in declarations from a commons, use `uses` instead",
2063                        ),
2064                    );
2065                    continue;
2066                }
2067                // v0.18: adapter dependencies are adapter-to-adapter (spec §4.5) —
2068                // an adapter consuming a *context* would pull service logic into
2069                // the host boundary.
2070                if kind == UnitKind::Adapter && target_kind == UnitKind::Context {
2071                    errors.push_for(Some(&parsed[i].identity_path()),
2072                        CompileError::new(
2073                            "bynk.adapter.consumes_context",
2074                            c.span,
2075                            format!(
2076                                "adapter `{name}` cannot `consumes` the context `{target}` — adapter dependencies are adapter-to-adapter"
2077                            ),
2078                        )
2079                        .with_note(
2080                            "an adapter may only depend on capabilities exported by other adapters (e.g. the `bynk` surface)",
2081                        ),
2082                    );
2083                    continue;
2084                }
2085                if target == *name {
2086                    let kind_word = if kind == UnitKind::Adapter {
2087                        "adapter"
2088                    } else {
2089                        "context"
2090                    };
2091                    errors.push_for(
2092                        Some(&parsed[i].identity_path()),
2093                        CompileError::new(
2094                            "bynk.consumes.self_reference",
2095                            c.span,
2096                            format!("{kind_word} `{name}` cannot `consumes` itself"),
2097                        ),
2098                    );
2099                    continue;
2100                }
2101                if !parsed[i].is_synthetic()
2102                    && let Some(err) = policy.check(&target, c.target.span)
2103                {
2104                    errors.push_for(Some(&parsed[i].identity_path()), err);
2105                }
2106                // v0.17: `consumes U { Cap, … }` — validate each selected name is
2107                // a capability `U` exports, detect clashes, and record the
2108                // flattening so bare `given Cap` resolves through the local path.
2109                if let Some(names) = &c.selected {
2110                    let exported = unit_tables
2111                        .get(&target)
2112                        .map(|t| &t.exported_capabilities)
2113                        .cloned()
2114                        .unwrap_or_default();
2115                    for cap in names {
2116                        if !parsed[i].is_synthetic()
2117                            && let Some(err) =
2118                                policy.check(&format!("{target}.{}", cap.name), cap.span)
2119                        {
2120                            errors.push_for(Some(&parsed[i].identity_path()), err);
2121                        }
2122                        if !exported.contains(&cap.name) {
2123                            errors.push_for(
2124                                Some(&parsed[i].identity_path()),
2125                                CompileError::new(
2126                                    "bynk.given.cross_context_unknown_capability",
2127                                    cap.span,
2128                                    format!(
2129                                        "`{target}` does not export a capability named `{}`",
2130                                        cap.name
2131                                    ),
2132                                ),
2133                            );
2134                            continue;
2135                        }
2136                        if local_caps.contains(&cap.name) {
2137                            errors.push_for(Some(&parsed[i].identity_path()), CompileError::new(
2138                                "bynk.consumes.capability_name_clash",
2139                                cap.span,
2140                                format!(
2141                                    "flattened capability `{}` clashes with a capability declared locally — use qualified `given {target}.{}` instead",
2142                                    cap.name, cap.name
2143                                ),
2144                            ));
2145                            continue;
2146                        }
2147                        if let Some(prev) = flattened.get(&cap.name) {
2148                            // #1857: the same selection, repeated in another
2149                            // file of the context, is one selection.
2150                            if *prev == target && flattened_in.get(&cap.name) != Some(&i) {
2151                                refs.record_in_unit(
2152                                    cap.span,
2153                                    SymbolKind::Capability,
2154                                    &cap.name,
2155                                    &target,
2156                                );
2157                                continue;
2158                            }
2159                            errors.push_for(Some(&parsed[i].identity_path()), CompileError::new(
2160                                "bynk.consumes.capability_name_clash",
2161                                cap.span,
2162                                format!(
2163                                    "capability `{}` is flattened from both `{prev}` and `{target}` — qualify one with `given U.{}`",
2164                                    cap.name, cap.name
2165                                ),
2166                            ));
2167                            continue;
2168                        }
2169                        // v0.25: the selection list names the capability in
2170                        // the consumed unit (clause-position reference).
2171                        refs.record_in_unit(cap.span, SymbolKind::Capability, &cap.name, &target);
2172                        flattened.insert(cap.name.clone(), target.clone());
2173                        flattened_in.insert(cap.name.clone(), i);
2174                    }
2175                }
2176                if !consumes_targets.contains(&target) {
2177                    consumes_targets.push(target);
2178                }
2179            }
2180        }
2181        unit_consumes.insert(name.clone(), consumes_targets);
2182        unit_flattened.insert(name.clone(), flattened);
2183    }
2184    (unit_consumes, unit_flattened)
2185}
2186
2187/// Phases 5b'/5b'': collect each context's `consumes` aliases (alias →
2188/// consumed-context name), reporting alias-vs-alias conflicts (5b'), then report
2189/// any alias that clashes with a locally-declared type/fn/capability/service/agent
2190/// (5b''). Returns the per-context alias maps; diagnostics go into `errors`.
2191pub fn phase_consumes_aliases(
2192    groups: &BTreeMap<String, Vec<usize>>,
2193    kinds: &BTreeMap<String, UnitKind>,
2194    parsed: &[ParsedFile],
2195    unit_tables: &HashMap<String, UnitTable>,
2196    errors: &mut ErrorSink,
2197) -> HashMap<String, HashMap<String, String>> {
2198    let mut unit_consumes_aliases: HashMap<String, HashMap<String, String>> = HashMap::new();
2199    for (name, indices) in groups {
2200        let kind = *kinds.get(name).unwrap();
2201        if kind != UnitKind::Context {
2202            continue;
2203        }
2204        let mut aliases: HashMap<String, String> = HashMap::new();
2205        // #1857: where each alias, and each aliased target, was first seen:
2206        // `(the other half of the pair, file index, alias span)`. A context's
2207        // `consumes` clauses apply to all its files, so a file may repeat one
2208        // another file states, as it repeats `context`; only a pair that
2209        // disagrees is a conflict.
2210        let mut by_alias: HashMap<String, (String, usize, Span)> = HashMap::new();
2211        let mut by_target: HashMap<String, (String, usize, Span)> = HashMap::new();
2212        for &i in indices {
2213            for c in parsed[i].consumes() {
2214                let Some(alias) = &c.alias else { continue };
2215                let target = c.target.joined();
2216                if !unit_tables.contains_key(&target) {
2217                    // Already reported as unknown context above.
2218                    continue;
2219                }
2220                // #1857: a repeat is fine in *another* file; twice in one file
2221                // is still a conflict.
2222                let first = by_alias
2223                    .get(&alias.name)
2224                    .filter(|(t, f, _)| *t != target || *f == i)
2225                    .map(|(t, f, sp)| {
2226                        let what = if *t == target {
2227                            "the same clause appears earlier in this file".to_string()
2228                        } else {
2229                            format!("alias `{}` already names `{t}`", alias.name)
2230                        };
2231                        (what, *f, *sp)
2232                    })
2233                    .or_else(|| {
2234                        by_target
2235                            .get(&target)
2236                            .filter(|(a, _, _)| *a != alias.name)
2237                            .map(|(a, f, sp)| {
2238                                (format!("`{target}` is already consumed as `{a}`"), *f, *sp)
2239                            })
2240                    });
2241                if let Some((what, prev_file, prev_span)) = first {
2242                    // A label can point only into this file; another file's
2243                    // clause is named in the message.
2244                    let where_ = if prev_file == i {
2245                        String::new()
2246                    } else {
2247                        // `/`-separated on every platform, as the project
2248                        // layout is written (a Windows `display()` uses `\`).
2249                        format!(
2250                            " (first stated in `{}`)",
2251                            parsed[prev_file]
2252                                .identity_path()
2253                                .components()
2254                                .map(|c| c.as_os_str().to_string_lossy())
2255                                .collect::<Vec<_>>()
2256                                .join("/")
2257                        )
2258                    };
2259                    let mut err = CompileError::new(
2260                        "bynk.consumes.alias_conflict",
2261                        alias.span,
2262                        format!(
2263                            "`consumes {target} as {}` conflicts with another `consumes` clause in context `{name}`: {what}{where_}",
2264                            alias.name
2265                        ),
2266                    );
2267                    if prev_file == i {
2268                        err = err.with_label(prev_span, "previously defined here");
2269                    }
2270                    errors.push_for(
2271                        Some(&parsed[i].identity_path()),
2272                        err.with_note(
2273                            "a context may consume a unit under one alias, and an alias may name one unit; repeating the same clause in another file is fine",
2274                        ),
2275                    );
2276                    continue;
2277                }
2278                if by_alias.contains_key(&alias.name) {
2279                    // #1857: the same clause, repeated in another file.
2280                    continue;
2281                }
2282                aliases.insert(alias.name.clone(), target.clone());
2283                by_alias.insert(alias.name.clone(), (target.clone(), i, alias.span));
2284                by_target.insert(target, (alias.name.clone(), i, alias.span));
2285            }
2286        }
2287        unit_consumes_aliases.insert(name.clone(), aliases);
2288    }
2289
2290    // -- 5b''. Detect alias-vs-local-decl conflicts. An alias must not clash
2291    //          with any locally declared type/fn/capability/service/agent.
2292    for (name, aliases) in &unit_consumes_aliases {
2293        let Some(local) = unit_tables.get(name) else {
2294            continue;
2295        };
2296        for alias in aliases.keys() {
2297            let alias_site = parsed_alias_span(parsed, &groups[name], alias);
2298            let alias_span = alias_site.map(|(_, s)| s).unwrap_or_default();
2299            let alias_file = alias_site.map(|(i, _)| parsed[i].identity_path());
2300            let conflict_kind = if local.types.contains_key(alias) {
2301                Some("type")
2302            } else if local.fns.contains_key(alias) {
2303                Some("function")
2304            } else if local.capabilities.contains_key(alias) {
2305                Some("capability")
2306            } else if local.services.contains_key(alias) {
2307                Some("service")
2308            } else if local.agents.contains_key(alias) {
2309                Some("agent")
2310            } else {
2311                None
2312            };
2313            if let Some(kind) = conflict_kind {
2314                errors.push_for(alias_file.as_deref(),
2315                    CompileError::new(
2316                        "bynk.consumes.alias_conflict",
2317                        alias_span,
2318                        format!(
2319                            "alias `{alias}` conflicts with a local {kind} of the same name in context `{name}`",
2320                        ),
2321                    )
2322                    .with_note(
2323                        "pick a different alias for the `consumes` clause, or rename the local declaration",
2324                    ),
2325                );
2326            }
2327        }
2328    }
2329    unit_consumes_aliases
2330}
2331
2332/// Phase 6: for each unit, detect when two `uses`-imported commons declare the
2333/// same (non-shadowed) type or function name — an unrenamable conflict at the use
2334/// site. Diagnostics go into `errors`.
2335pub fn phase_uses_name_conflicts(
2336    unit_uses: &HashMap<String, Vec<String>>,
2337    unit_tables: &HashMap<String, UnitTable>,
2338    parsed: &[ParsedFile],
2339    groups: &BTreeMap<String, Vec<usize>>,
2340    errors: &mut ErrorSink,
2341) {
2342    for (name, targets) in unit_uses {
2343        let local = unit_tables.get(name).expect("unit table present");
2344        let mut imported: HashMap<String, String> = HashMap::new();
2345        for t in targets {
2346            let used = unit_tables.get(t).expect("used unit table present");
2347            for type_name in used.types.keys() {
2348                // Unlike the fn and asset loops below, a local service, agent,
2349                // provider or asset does not skip this (#1927, review of
2350                // #1933). It hides an imported type from naming, but the type
2351                // stays composed so its values resolve, and with two used
2352                // commons declaring it, which one the unit's values carry is
2353                // still ambiguous.
2354                if local.types.contains_key(type_name) || local.fns.contains_key(type_name) {
2355                    continue;
2356                }
2357                if let Some(prev) = imported.get(type_name) {
2358                    let site = uses_span_of(parsed, &groups[name], t);
2359                    let span = site.map(|(_, s)| s).unwrap_or_default();
2360                    let file = site.map(|(i, _)| parsed[i].identity_path());
2361                    errors.push_for(file.as_deref(),
2362                        CompileError::new(
2363                            "bynk.uses.name_conflict",
2364                            span,
2365                            format!(
2366                                "`{name}` uses two commons that both declare `{type_name}`: `{prev}` and `{t}`",
2367                            ),
2368                        )
2369                        .with_note(
2370                            "name conflicts at the use site are not yet renamable; remove or restructure one of the imports",
2371                        ),
2372                    );
2373                } else {
2374                    imported.insert(type_name.clone(), t.clone());
2375                }
2376            }
2377            for fn_name in used.fns.keys() {
2378                // #1925: any local value hides the import (`shadowed_imports`),
2379                // so two used commons declaring it leave nothing ambiguous.
2380                if crate::symbols::local_value_kind(local, fn_name).is_some() {
2381                    continue;
2382                }
2383                if let Some(prev) = imported.get(fn_name) {
2384                    let site = uses_span_of(parsed, &groups[name], t);
2385                    let span = site.map(|(_, s)| s).unwrap_or_default();
2386                    let file = site.map(|(i, _)| parsed[i].identity_path());
2387                    errors.push_for(file.as_deref(),
2388                        CompileError::new(
2389                            "bynk.uses.name_conflict",
2390                            span,
2391                            format!(
2392                                "`{name}` uses two commons that both declare `{fn_name}`: `{prev}` and `{t}`",
2393                            ),
2394                        )
2395                        .with_note(
2396                            "name conflicts at the use site are not yet renamable; remove or restructure one of the imports",
2397                        ),
2398                    );
2399                } else {
2400                    imported.insert(fn_name.clone(), t.clone());
2401                }
2402            }
2403            // #1883, slice 1a ([DECISION G]): a commons' assets come in
2404            // through `uses` like its functions, and share their namespace.
2405            for asset_name in used.assets.keys() {
2406                // #1925: any local value hides the import, as above.
2407                if crate::symbols::local_value_kind(local, asset_name).is_some() {
2408                    continue;
2409                }
2410                if let Some(prev) = imported.get(asset_name) {
2411                    let site = uses_span_of(parsed, &groups[name], t);
2412                    let span = site.map(|(_, s)| s).unwrap_or_default();
2413                    let file = site.map(|(i, _)| parsed[i].identity_path());
2414                    errors.push_for(file.as_deref(),
2415                        CompileError::new(
2416                            "bynk.uses.name_conflict",
2417                            span,
2418                            format!(
2419                                "`{name}` uses two commons that both declare `{asset_name}`: `{prev}` and `{t}`",
2420                            ),
2421                        )
2422                        .with_note(
2423                            "name conflicts at the use site are not yet renamable; remove or restructure one of the imports",
2424                        ),
2425                    );
2426                } else {
2427                    imported.insert(asset_name.clone(), t.clone());
2428                }
2429            }
2430            // #2387, slice 1: a commons' embeds come in through `uses` the
2431            // same way, in the same namespace.
2432            for embed_name in used.embeds.keys() {
2433                if crate::symbols::local_value_kind(local, embed_name).is_some() {
2434                    continue;
2435                }
2436                if let Some(prev) = imported.get(embed_name) {
2437                    let site = uses_span_of(parsed, &groups[name], t);
2438                    let span = site.map(|(_, s)| s).unwrap_or_default();
2439                    let file = site.map(|(i, _)| parsed[i].identity_path());
2440                    errors.push_for(file.as_deref(),
2441                        CompileError::new(
2442                            "bynk.uses.name_conflict",
2443                            span,
2444                            format!(
2445                                "`{name}` uses two commons that both declare `{embed_name}`: `{prev}` and `{t}`",
2446                            ),
2447                        )
2448                        .with_note(
2449                            "name conflicts at the use site are not yet renamable; remove or restructure one of the imports",
2450                        ),
2451                    );
2452                } else {
2453                    imported.insert(embed_name.clone(), t.clone());
2454                }
2455            }
2456        }
2457    }
2458}
2459
2460/// message-bundles slice 1 (#859): messages-block legality, `@reference`
2461/// cardinality, within-block duplicate codes, and the `uses bynk.locale`
2462/// dependency. Runs here (not in `phase_group`) because it needs `unit_uses`,
2463/// resolved just above.
2464///
2465/// P5.0 (`design/tracks/semantics-in-the-checker.md` §6): relocated verbatim
2466/// from `bynk-emit/src/project/validate.rs`'s `check_messages_bundles` — one
2467/// of the two live editor-diagnostics regressions this slice closes (category
2468/// 2 of `analysis.rs`'s own seven-category accounting). Cross-locale
2469/// completeness (`bynk.messages.incomplete`, only for codes present in the
2470/// reference locale but not this one — a locale-specific-only code is not an
2471/// error, per the "reference is a floor, not a ceiling" convention) and
2472/// cross-locale placeholder-*set* agreement (`bynk.messages.placeholder_mismatch`,
2473/// only for codes present in both — a missing code is `incomplete`'s job, not
2474/// this one's). Two blocks declaring the same locale tag are rejected outright
2475/// (`bynk.resolve.duplicate_message_locale`, PR #875 review) — the emitter
2476/// has no dedup of its own, so a silent last-wins here would let a hard
2477/// `tsc` redeclare error (two colliding `const __messages_<tag>`
2478/// declarations) through instead.
2479pub fn phase_messages_bundles(
2480    parsed: &[ParsedFile],
2481    groups: &BTreeMap<String, Vec<usize>>,
2482    kinds: &BTreeMap<String, UnitKind>,
2483    unit_uses: &HashMap<String, Vec<String>>,
2484    errors: &mut ErrorSink,
2485) {
2486    for (name, indices) in groups {
2487        let mut first_messages: Option<(usize, Span)> = None;
2488        let mut reference_sites: Vec<(usize, Span)> = Vec::new();
2489        let mut reference_block: Option<(usize, &MessagesDecl)> = None;
2490        let mut by_tag: HashMap<&str, (usize, &MessagesDecl)> = HashMap::new();
2491        for &i in indices {
2492            for item in parsed[i].items() {
2493                let CommonsItem::Messages(m) = item else {
2494                    continue;
2495                };
2496                if first_messages.is_none() {
2497                    first_messages = Some((i, m.span));
2498                }
2499                if kinds.get(name) != Some(&UnitKind::Commons) {
2500                    errors.push_for(
2501                        Some(&parsed[i].identity_path()),
2502                        CompileError::new(
2503                            "bynk.messages.outside_commons",
2504                            m.span,
2505                            "`messages` declarations are only allowed inside a commons, not a context or adapter",
2506                        ),
2507                    );
2508                    continue;
2509                }
2510                // #899: the tag is a `LocaleTag` string literal, checked here
2511                // against `LocaleTag`'s own refinement (read from the
2512                // firstparty `bynk.locale.types` source, so the pattern has one
2513                // definition). An invalid tag would otherwise reach `Intl` at
2514                // runtime as `new Intl.PluralRules("xx")`, which throws — the
2515                // opposite of `render`'s totality contract.
2516                if !checker::locale_tag_accepts(&m.tag) {
2517                    let pattern = checker::locale_tag_pattern().unwrap_or("");
2518                    errors.push_for(
2519                        Some(&parsed[i].identity_path()),
2520                        CompileError::new(
2521                            "bynk.messages.invalid_locale_tag",
2522                            m.tag_span,
2523                            format!(
2524                                "\"{}\" is not a valid `LocaleTag` — it must match the pattern `{}`",
2525                                m.tag, pattern
2526                            ),
2527                        ),
2528                    );
2529                }
2530                // message-bundles slice 2 (#874, PR #875 review): two blocks
2531                // declaring the same locale tag are rejected, not
2532                // last-write-wins — the emitter (`emit_messages_bundle`) has no
2533                // dedup of its own and would emit two colliding table entries
2534                // under one object key, a hard `tsc` error. Mirrors
2535                // `bynk.resolve.duplicate_fn`'s own shape: only the *first*
2536                // occurrence seeds `by_tag`, so a third duplicate still reports
2537                // against the original, not the second.
2538                if let Some(&(_, prev)) = by_tag.get(m.tag.as_str()) {
2539                    errors.push_for(
2540                        Some(&parsed[i].identity_path()),
2541                        CompileError::new(
2542                            "bynk.resolve.duplicate_message_locale",
2543                            m.tag_span,
2544                            format!("locale \"{}\" is already declared in this bundle", m.tag),
2545                        )
2546                        .with_label(prev.tag_span, "previously declared here"),
2547                    );
2548                } else {
2549                    by_tag.insert(m.tag.as_str(), (i, m));
2550                }
2551                for ann in &m.annotations {
2552                    if ann.name.name == "reference" {
2553                        reference_sites.push((i, ann.span));
2554                        reference_block = Some((i, m));
2555                    }
2556                }
2557                let mut seen: HashMap<&str, Span> = HashMap::new();
2558                for entry in &m.entries {
2559                    if let Some(prev) = seen.get(entry.code.as_str()) {
2560                        errors.push_for(
2561                            Some(&parsed[i].identity_path()),
2562                            CompileError::new(
2563                                "bynk.resolve.duplicate_message_code",
2564                                entry.code_span,
2565                                format!(
2566                                    "message code \"{}\" is already declared in this block",
2567                                    entry.code
2568                                ),
2569                            )
2570                            .with_label(*prev, "previously declared here"),
2571                        );
2572                    } else {
2573                        seen.insert(entry.code.as_str(), entry.code_span);
2574                    }
2575                    // message-bundles slice 3 (#878): runs unconditionally,
2576                    // once per entry, regardless of `@reference` cardinality
2577                    // — malformed ICU syntax shouldn't wait on cardinality
2578                    // being resolved first.
2579                    check_entry_icu_syntax(entry, Some(&parsed[i].identity_path()), errors);
2580                }
2581            }
2582        }
2583        let Some((first_i, first_span)) = first_messages else {
2584            continue;
2585        };
2586        if kinds.get(name) != Some(&UnitKind::Commons) {
2587            // Already reported above (outside_commons) for every block;
2588            // cardinality/uses checks don't apply to a non-commons unit.
2589            continue;
2590        }
2591        match reference_sites.len() {
2592            0 => {
2593                errors.push_for(
2594                    Some(&parsed[first_i].identity_path()),
2595                    CompileError::new(
2596                        "bynk.messages.missing_reference",
2597                        first_span,
2598                        "a message bundle must have exactly one `@reference` block; none found",
2599                    ),
2600                );
2601            }
2602            1 => {
2603                // message-bundles slice 2 (#874): "the reference" is only
2604                // well-defined here — 0 or 2+ already reported their own
2605                // diagnostic above, and completeness/placeholder-agreement
2606                // against an ambiguous or absent reference would be noise.
2607                let (_, reference) = reference_block
2608                    .expect("reference_sites.len() == 1 implies reference_block is Some");
2609                // Sorted for deterministic diagnostic order — `by_tag`'s
2610                // HashMap iteration is not otherwise stable across runs.
2611                let mut sorted_tags: Vec<&&str> = by_tag.keys().collect();
2612                sorted_tags.sort();
2613                for &&tag in &sorted_tags {
2614                    let &(locale_i, locale_m) = &by_tag[tag];
2615                    if tag == reference.tag.as_str() {
2616                        continue;
2617                    }
2618                    for ref_entry in &reference.entries {
2619                        let Some(locale_entry) =
2620                            locale_m.entries.iter().find(|e| e.code == ref_entry.code)
2621                        else {
2622                            errors.push_for(
2623                                Some(&parsed[locale_i].identity_path()),
2624                                CompileError::new(
2625                                    "bynk.messages.incomplete",
2626                                    locale_m.span,
2627                                    format!(
2628                                        "locale \"{tag}\" is missing code \"{}\", declared by the reference locale \"{}\"",
2629                                        ref_entry.code, reference.tag
2630                                    ),
2631                                ),
2632                            );
2633                            continue;
2634                        };
2635                        let ref_names = icu::placeholder_names(&ref_entry.template);
2636                        let locale_names = icu::placeholder_names(&locale_entry.template);
2637                        if ref_names != locale_names {
2638                            errors.push_for(
2639                                Some(&parsed[locale_i].identity_path()),
2640                                CompileError::new(
2641                                    "bynk.messages.placeholder_mismatch",
2642                                    locale_entry.template_span,
2643                                    format!(
2644                                        "locale \"{tag}\"'s template for code \"{}\" uses placeholders {locale_names:?}, but the reference locale \"{}\"'s uses {ref_names:?}",
2645                                        ref_entry.code, reference.tag
2646                                    ),
2647                                ),
2648                            );
2649                        }
2650                        // message-bundles slice 3 (#878, Decision D): a name
2651                        // present in both templates must also agree on ICU
2652                        // format *kind* (plain/plural/select/number/date) —
2653                        // a UI can't sanely alternate that per locale. A
2654                        // missing name is `placeholder_mismatch`'s job, not
2655                        // this one's; a malformed template's kinds are
2656                        // silently absent from `template_format_kinds`
2657                        // (already reported once by `check_entry_icu_syntax`
2658                        // above, never double-reported here).
2659                        let ref_kinds = icu::template_format_kinds(&ref_entry.template);
2660                        let locale_kinds = icu::template_format_kinds(&locale_entry.template);
2661                        for (pname, ref_kind) in &ref_kinds {
2662                            let Some(locale_kind) = locale_kinds.get(pname) else {
2663                                continue;
2664                            };
2665                            if locale_kind != ref_kind {
2666                                errors.push_for(
2667                                    Some(&parsed[locale_i].identity_path()),
2668                                    CompileError::new(
2669                                        "bynk.messages.format_mismatch",
2670                                        locale_entry.template_span,
2671                                        format!(
2672                                            "locale \"{tag}\"'s placeholder \"{pname}\" in code \"{}\" is formatted as {}, but the reference locale \"{}\"'s is {}",
2673                                            ref_entry.code,
2674                                            locale_kind.as_str(),
2675                                            reference.tag,
2676                                            ref_kind.as_str(),
2677                                        ),
2678                                    ),
2679                                );
2680                            }
2681                        }
2682                    }
2683                }
2684            }
2685            _ => {
2686                let (_, first_ref_span) = reference_sites[0];
2687                for &(i, span) in &reference_sites[1..] {
2688                    errors.push_for(
2689                        Some(&parsed[i].identity_path()),
2690                        CompileError::new(
2691                            "bynk.messages.multiple_reference",
2692                            span,
2693                            "a message bundle must have exactly one `@reference` block; found more than one",
2694                        )
2695                        .with_label(first_ref_span, "first `@reference` here"),
2696                    );
2697                }
2698            }
2699        }
2700        // Locale-negotiation-slice-2 follow-up (#886): the synthetic `render`
2701        // this commons gets (`synthetic_render_fn`, symbols.rs) names
2702        // `LocaleTag`/`Message` by `TypeRef::Named` — real, resolved
2703        // references, not bypassed — so both `bynk.locale` (for `render`
2704        // itself) and `bynk.locale.types` (for the types its signature
2705        // names) must be `uses`d. Kept as one diagnostic, not two: a message
2706        // bundle always needs both together, so splitting the code would
2707        // just be two author-facing fixes for one underlying requirement.
2708        let targets = unit_uses.get(name);
2709        let has_locale_uses =
2710            targets.is_some_and(|targets| targets.iter().any(|t| t == firstparty::LOCALE_UNIT));
2711        let has_locale_types_uses = targets
2712            .is_some_and(|targets| targets.iter().any(|t| t == firstparty::LOCALE_TYPES_UNIT));
2713        if !has_locale_uses || !has_locale_types_uses {
2714            let missing = match (has_locale_uses, has_locale_types_uses) {
2715                (false, false) => "`bynk.locale` and `bynk.locale.types`",
2716                (false, true) => "`bynk.locale`",
2717                (true, false) => "`bynk.locale.types`",
2718                (true, true) => unreachable!("at least one of the two is missing here"),
2719            };
2720            errors.push_for(
2721                Some(&parsed[first_i].identity_path()),
2722                CompileError::new(
2723                    "bynk.messages.missing_locale_dependency",
2724                    first_span,
2725                    format!("a commons declaring `messages` must also `uses` {missing}"),
2726                ),
2727            );
2728        }
2729    }
2730}
2731
2732/// Locale capability track, slice 2 (#882): a context whose direct `uses`
2733/// reaches two or more message-bundle commons has no principled single
2734/// answer for what `Locale.current()` should negotiate against — but this
2735/// is only worth diagnosing when the context actually `consumes bynk {
2736/// Locale }` at all; a context with 2+ bundles that never touches `Locale`
2737/// has nothing ambiguous to resolve.
2738///
2739/// P5.0 (`design/tracks/semantics-in-the-checker.md` §6): relocated verbatim
2740/// from `bynk-emit/src/project/validate.rs`'s `check_locale_bundle_ambiguity`
2741/// — category 3 of `analysis.rs`'s own seven-category accounting, the second
2742/// of this slice's two live editor-diagnostics regressions.
2743pub fn phase_locale_bundle_ambiguity(
2744    parsed: &[ParsedFile],
2745    groups: &BTreeMap<String, Vec<usize>>,
2746    kinds: &BTreeMap<String, UnitKind>,
2747    unit_uses: &HashMap<String, Vec<String>>,
2748    unit_flattened: &HashMap<String, HashMap<String, String>>,
2749    errors: &mut ErrorSink,
2750) {
2751    for (name, indices) in groups {
2752        if kinds.get(name) != Some(&UnitKind::Context) {
2753            continue;
2754        }
2755        let ContextMessageBundle::Many(bundles) =
2756            detect_context_message_bundle(name, unit_uses, groups, kinds, parsed)
2757        else {
2758            continue;
2759        };
2760        let consumes_locale = unit_flattened
2761            .get(name)
2762            .and_then(|m| m.get("Locale"))
2763            .is_some_and(|owner| owner == firstparty::BYNK_UNIT);
2764        if !consumes_locale {
2765            continue;
2766        }
2767        for &i in indices {
2768            for c in parsed[i].consumes() {
2769                if c.target.joined() != firstparty::BYNK_UNIT {
2770                    continue;
2771                }
2772                let Some(locale_ident) = c.selected.iter().flatten().find(|id| id.name == "Locale")
2773                else {
2774                    continue;
2775                };
2776                let mut err = CompileError::new(
2777                    "bynk.locale.multiple_message_bundles",
2778                    locale_ident.span,
2779                    format!(
2780                        "context `{name}` uses {} message bundles ({}) — `Locale.current()` has no single bundle to negotiate against",
2781                        bundles.len(),
2782                        bundles.join(", "),
2783                    ),
2784                );
2785                for &j in indices {
2786                    for u in parsed[j].uses() {
2787                        if bundles.contains(&u.target.joined()) {
2788                            err = err
2789                                .with_label(u.span, format!("`{}` used here", u.target.joined()));
2790                        }
2791                    }
2792                }
2793                errors.push_for(Some(&parsed[i].identity_path()), err);
2794            }
2795        }
2796    }
2797}
2798
2799/// A message bundle entry's ICU template, syntax-checked at parse time
2800/// against the ICU MessageFormat grammar `icu.rs` implements. Relocated
2801/// alongside `phase_messages_bundles` (P5.0) — its only caller.
2802fn check_entry_icu_syntax(entry: &MessageEntry, file: Option<&Path>, errors: &mut ErrorSink) {
2803    for (inner_offset, inner) in icu::icu_dispatch_placeholders(&entry.template) {
2804        if let Err(e) = icu::parse_icu_placeholder(inner) {
2805            let decoded_start = inner_offset + e.offset;
2806            let decoded_span = Span::new(decoded_start, decoded_start + e.len);
2807            let raw_span = decoded_span.offset(entry.template_span.start + 1);
2808            errors.push_for(
2809                file,
2810                CompileError::new(
2811                    "bynk.messages.malformed_icu_syntax",
2812                    raw_span,
2813                    e.kind.message(),
2814                ),
2815            );
2816        }
2817    }
2818}
2819
2820/// Events track, slice 0 (spine #936): a `from Events(E)` subscription must
2821/// name a real, declared event — owned either by this context or by a
2822/// context it `consumes` (mirroring `discover_event_subscribers`'s own
2823/// ownership resolution, `project.rs`, which silently drops an unresolvable
2824/// subscription rather than diagnosing it). Runs at the project-wide phase
2825/// (needs `unit_tables` + `unit_consumes` together, unlike the local, per-
2826/// context `check_service_protocols`), alongside the other cross-unit checks
2827/// that need the same two maps.
2828///
2829/// P5.1 (`design/tracks/semantics-in-the-checker.md` §6): relocated verbatim
2830/// from `bynk-emit/src/project/validate.rs`'s `check_event_subscriptions` —
2831/// category 4 of `analysis.rs`'s own seven-category accounting, the third
2832/// live editor-diagnostics regression this track closes.
2833pub fn phase_event_subscriptions(
2834    parsed: &[ParsedFile],
2835    groups: &BTreeMap<String, Vec<usize>>,
2836    kinds: &BTreeMap<String, UnitKind>,
2837    unit_tables: &HashMap<String, UnitTable>,
2838    unit_consumes: &HashMap<String, Vec<String>>,
2839    unit_uses: &HashMap<String, Vec<String>>,
2840    errors: &mut ErrorSink,
2841) {
2842    for (name, indices) in groups {
2843        if kinds.get(name) != Some(&UnitKind::Context) {
2844            continue;
2845        }
2846        let consumed = unit_consumes.get(name).cloned().unwrap_or_default();
2847        for &i in indices {
2848            for item in parsed[i].items() {
2849                let CommonsItem::Service(s) = item else {
2850                    continue;
2851                };
2852                let ServiceProtocol::Events {
2853                    event_type,
2854                    pattern,
2855                    schema_dispatch,
2856                } = &s.protocol
2857                else {
2858                    continue;
2859                };
2860                // Events track, slice 4 (spine #936): `via schema(N)`'s
2861                // legality needs nothing about the subscribed event itself
2862                // (unlike the payload pattern below), so it's checked
2863                // independently of whether the subscription even resolves.
2864                if let Some(dispatch) = schema_dispatch {
2865                    check_schema_dispatch(dispatch, &parsed[i].identity_path(), errors);
2866                }
2867                let TypeRef::Named(id) = event_type else {
2868                    continue;
2869                };
2870                let owner_locally = unit_tables
2871                    .get(name)
2872                    .filter(|t| t.events.contains_key(&id.name))
2873                    .map(|_| name.clone());
2874                let owner_consumed = consumed.iter().find(|c| {
2875                    unit_tables
2876                        .get(*c)
2877                        .is_some_and(|t| t.events.contains_key(&id.name))
2878                });
2879                let owner = owner_locally
2880                    .as_deref()
2881                    .or(owner_consumed.map(String::as_str));
2882                let Some(owner) = owner else {
2883                    errors.push_for(
2884                        Some(&parsed[i].identity_path()),
2885                        CompileError::new(
2886                            "bynk.event.unknown_subscription",
2887                            id.span,
2888                            format!(
2889                                "`{}` is not a declared event in this context or any consumed context",
2890                                id.name
2891                            ),
2892                        )
2893                        .with_note(
2894                            "check the spelling, or add `consumes <context>` for the context whose `event` this names — an unresolvable subscription never receives anything, silently",
2895                        ),
2896                    );
2897                    continue;
2898                };
2899                // Events track, slice 1 (spine #936): once the event itself
2900                // resolves, check the subscription pattern's fields against
2901                // its declared record shape. No pattern is the pattern-less
2902                // form (slice 0) and needs none of this.
2903                let Some(pattern) = pattern else {
2904                    continue;
2905                };
2906                let Some(event_decl) = unit_tables.get(owner).and_then(|t| t.events.get(&id.name))
2907                else {
2908                    continue;
2909                };
2910                check_event_pattern(
2911                    pattern,
2912                    event_decl,
2913                    owner,
2914                    unit_tables,
2915                    unit_uses,
2916                    &parsed[i].identity_path(),
2917                    errors,
2918                );
2919            }
2920        }
2921    }
2922}
2923
2924/// Events track, slice 1 (spine #936): resolve a subscription pattern's
2925/// fields/values against the owning event's declared record shape. `owner`
2926/// is the context that declares `event_decl` (may differ from the
2927/// subscribing context, reached via `consumes`) — a field's own type (e.g. a
2928/// discriminator sum like `Region`) resolves against the *owner's* types
2929/// (locally declared, or pulled in via the owner's own `uses <commons>`),
2930/// mirroring how the field's type is resolved everywhere else the event's
2931/// record shape is used.
2932fn check_event_pattern(
2933    pattern: &EventPattern,
2934    event_decl: &EventDecl,
2935    owner: &str,
2936    unit_tables: &HashMap<String, UnitTable>,
2937    unit_uses: &HashMap<String, Vec<String>>,
2938    identity_path: &std::path::Path,
2939    errors: &mut ErrorSink,
2940) {
2941    let mut seen: HashSet<String> = HashSet::new();
2942    for field in &pattern.fields {
2943        if !seen.insert(field.name.name.clone()) {
2944            errors.push_for(
2945                Some(identity_path),
2946                CompileError::new(
2947                    "bynk.event.pattern_duplicate_field",
2948                    field.name.span,
2949                    format!(
2950                        "field `{}` is matched more than once in this subscription pattern",
2951                        field.name.name
2952                    ),
2953                ),
2954            );
2955            continue;
2956        }
2957        let Some(record_field) = event_decl
2958            .body
2959            .fields
2960            .iter()
2961            .find(|f| f.name.name == field.name.name)
2962        else {
2963            let known: Vec<&str> = event_decl
2964                .body
2965                .fields
2966                .iter()
2967                .map(|f| f.name.name.as_str())
2968                .collect();
2969            errors.push_for(
2970                Some(identity_path),
2971                CompileError::new(
2972                    "bynk.event.pattern_unknown_field",
2973                    field.name.span,
2974                    format!(
2975                        "`{}` has no field named `{}`",
2976                        event_decl.name.name, field.name.name
2977                    ),
2978                )
2979                .with_note(format!(
2980                    "declared fields: {}",
2981                    if known.is_empty() {
2982                        "(none)".to_string()
2983                    } else {
2984                        known.join(", ")
2985                    }
2986                )),
2987            );
2988            continue;
2989        };
2990        check_event_pattern_value(
2991            &field.value,
2992            record_field,
2993            owner,
2994            unit_tables,
2995            unit_uses,
2996            identity_path,
2997            errors,
2998        );
2999    }
3000}
3001
3002/// Events track, slice 4 (spine #936): `via schema(N)`'s `N` must be a
3003/// positive `Int` literal — the identical rule `@schema(N)` already
3004/// enforces (`bynk.event.bad_schema_version`), reused under its own code
3005/// since the two are unrelated syntax positions (an annotation on the
3006/// event's own declaration vs. a clause on a subscriber's header).
3007fn check_schema_dispatch(
3008    dispatch: &SchemaDispatch,
3009    identity_path: &std::path::Path,
3010    errors: &mut ErrorSink,
3011) {
3012    let SchemaVersionPattern::Literal(n) = &dispatch.pattern;
3013    if *n <= 0 {
3014        errors.push_for(
3015            Some(identity_path),
3016            CompileError::new(
3017                "bynk.event.bad_schema_dispatch",
3018                dispatch.span,
3019                "`via schema(...)`'s argument must be a positive `Int` literal",
3020            ),
3021        );
3022    }
3023}
3024
3025/// Resolve one pattern field's matched value against that field's declared
3026/// type — a literal must match the field's base type; a variant must name a
3027/// nullary member of the field's sum type.
3028fn check_event_pattern_value(
3029    value: &EventPatternValue,
3030    record_field: &RecordField,
3031    owner: &str,
3032    unit_tables: &HashMap<String, UnitTable>,
3033    unit_uses: &HashMap<String, Vec<String>>,
3034    identity_path: &std::path::Path,
3035    errors: &mut ErrorSink,
3036) {
3037    match value {
3038        EventPatternValue::Literal { value: lit, span } => {
3039            // A base type (`Int`/`String`/`Bool`/…) is its own `TypeRef`
3040            // variant, not `TypeRef::Named` — only a *user*-declared type
3041            // (including a refined/opaque type built on a base) goes through
3042            // `resolve_type_decl`. An earlier version of this match only
3043            // handled the `Named` case, so a plain `orderId: String` field
3044            // (the common case) fell through to "not a literal-kind type",
3045            // caught by `events_workers_wiring.rs`'s patterned fixture.
3046            let base = match &record_field.type_ref {
3047                TypeRef::Base(b, _) => Some(*b),
3048                TypeRef::Named(field_type_name) => {
3049                    resolve_type_decl(unit_tables, unit_uses, owner, &field_type_name.name)
3050                        .and_then(|d| match &d.body {
3051                            TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. } => {
3052                                Some(*base)
3053                            }
3054                            _ => None,
3055                        })
3056                }
3057                _ => None,
3058            };
3059            let Some(base) = base else {
3060                errors.push_for(
3061                    Some(identity_path),
3062                    CompileError::new(
3063                        "bynk.event.pattern_type_mismatch",
3064                        *span,
3065                        format!(
3066                            "field `{}` is not a literal-kind type — a literal pattern value cannot match it",
3067                            record_field.name.name
3068                        ),
3069                    ),
3070                );
3071                return;
3072            };
3073            let kind_matches = matches!(
3074                (lit, base),
3075                (LiteralValue::Int(_), BaseType::Int)
3076                    | (LiteralValue::Str(_), BaseType::String)
3077                    | (LiteralValue::Bool(_), BaseType::Bool)
3078            );
3079            if !kind_matches {
3080                errors.push_for(
3081                    Some(identity_path),
3082                    CompileError::new(
3083                        "bynk.event.pattern_type_mismatch",
3084                        *span,
3085                        format!(
3086                            "this literal does not match the type of field `{}` (`{}`)",
3087                            record_field.name.name,
3088                            type_ref_to_display(&record_field.type_ref)
3089                        ),
3090                    ),
3091                );
3092            }
3093        }
3094        EventPatternValue::Variant {
3095            type_name,
3096            variant,
3097            span,
3098        } => {
3099            let TypeRef::Named(field_type_name) = &record_field.type_ref else {
3100                errors.push_for(
3101                    Some(identity_path),
3102                    CompileError::new(
3103                        "bynk.event.pattern_type_mismatch",
3104                        *span,
3105                        format!(
3106                            "field `{}` is not a sum type — a variant pattern value cannot match it",
3107                            record_field.name.name
3108                        ),
3109                    ),
3110                );
3111                return;
3112            };
3113            if let Some(qualifier) = type_name
3114                && qualifier.name != field_type_name.name
3115            {
3116                errors.push_for(
3117                    Some(identity_path),
3118                    CompileError::new(
3119                        "bynk.event.pattern_type_mismatch",
3120                        qualifier.span,
3121                        format!(
3122                            "field `{}` has type `{}`, not `{}`",
3123                            record_field.name.name, field_type_name.name, qualifier.name
3124                        ),
3125                    ),
3126                );
3127                return;
3128            }
3129            let Some(decl) =
3130                resolve_type_decl(unit_tables, unit_uses, owner, &field_type_name.name)
3131            else {
3132                // The field's own type failed to resolve — a different,
3133                // pre-existing check (ordinary type-reference resolution)
3134                // already reports this; don't double-report it here.
3135                return;
3136            };
3137            let TypeBody::Sum(sum) = &decl.body else {
3138                errors.push_for(
3139                    Some(identity_path),
3140                    CompileError::new(
3141                        "bynk.event.pattern_type_mismatch",
3142                        *span,
3143                        format!(
3144                            "field `{}` has type `{}`, which is not a sum type",
3145                            record_field.name.name, field_type_name.name
3146                        ),
3147                    ),
3148                );
3149                return;
3150            };
3151            let Some(member) = sum.variants.iter().find(|v| v.name.name == variant.name) else {
3152                errors.push_for(
3153                    Some(identity_path),
3154                    CompileError::new(
3155                        "bynk.event.pattern_unknown_variant",
3156                        variant.span,
3157                        format!(
3158                            "`{}` has no variant named `{}`",
3159                            field_type_name.name, variant.name
3160                        ),
3161                    ),
3162                );
3163                return;
3164            };
3165            if !member.payload.is_empty() {
3166                errors.push_for(
3167                    Some(identity_path),
3168                    CompileError::new(
3169                        "bynk.event.pattern_variant_payload",
3170                        variant.span,
3171                        format!(
3172                            "`{}.{}` carries a payload — only a nullary variant may be matched here, since testing the tag alone would silently ignore the payload",
3173                            field_type_name.name, variant.name
3174                        ),
3175                    ),
3176                );
3177            }
3178        }
3179    }
3180}
3181
3182/// Resolve a named type as `owner` sees it: the context's own `types` first,
3183/// then any commons unit it `uses`. Events track slice 1 (spine #936) needs
3184/// this because a pattern field's type (e.g. a discriminator sum) may be
3185/// declared in a commons the event's owning context pulls in with `uses`,
3186/// rather than in the context itself.
3187fn resolve_type_decl<'a>(
3188    unit_tables: &'a HashMap<String, UnitTable>,
3189    unit_uses: &HashMap<String, Vec<String>>,
3190    owner: &str,
3191    name: &str,
3192) -> Option<&'a Arc<TypeDecl>> {
3193    if let Some(t) = unit_tables.get(owner).and_then(|t| t.types.get(name)) {
3194        return Some(t);
3195    }
3196    for used in unit_uses.get(owner).into_iter().flatten() {
3197        if let Some(t) = unit_tables.get(used).and_then(|t| t.types.get(name)) {
3198            return Some(t);
3199        }
3200    }
3201    None
3202}
3203
3204/// Phase 6b: validate each context/adapter's `exports opaque/transparent { … }`
3205/// clauses — every name must be a locally-declared type, with no duplicates
3206/// within a clause or conflicting visibilities across clauses. Returns unit →
3207/// (type → visibility); diagnostics go into `errors` and export references into
3208/// `refs`.
3209pub fn phase_validate_type_exports(
3210    groups: &BTreeMap<String, Vec<usize>>,
3211    kinds: &BTreeMap<String, UnitKind>,
3212    parsed: &[ParsedFile],
3213    unit_tables: &HashMap<String, UnitTable>,
3214    errors: &mut ErrorSink,
3215    refs: &mut RefSink,
3216) -> HashMap<String, HashMap<String, Visibility>> {
3217    let mut exports_visibility: HashMap<String, HashMap<String, Visibility>> = HashMap::new();
3218    for (name, indices) in groups {
3219        let kind = *kinds.get(name).unwrap();
3220        if kind != UnitKind::Context && kind != UnitKind::Adapter {
3221            // Commons may not have exports clauses (parsed grammar prevents it
3222            // at the parser level), but in case any sneak in, skip.
3223            continue;
3224        }
3225        let local = unit_tables.get(name).unwrap();
3226        let mut seen: HashMap<String, (Visibility, Span)> = HashMap::new();
3227        for &i in indices {
3228            refs.enter_file(&parsed[i].identity_path(), name, parsed[i].is_synthetic());
3229            for clause in parsed[i].exports() {
3230                // v0.15: `exports capability { ... }` clauses are validated
3231                // separately (§4.1); 6b handles only type exports.
3232                let ExportKind::Type(clause_vis) = clause.kind else {
3233                    continue;
3234                };
3235                let mut within: HashMap<String, Span> = HashMap::new();
3236                for n in clause.names.iter().map(|e| &e.name) {
3237                    if let Some(prev) = within.get(&n.name) {
3238                        errors.push_for(
3239                            Some(&parsed[i].identity_path()),
3240                            CompileError::new(
3241                                "bynk.exports.duplicate_in_clause",
3242                                n.span,
3243                                format!(
3244                                    "type `{}` appears more than once in this exports clause",
3245                                    n.name
3246                                ),
3247                            )
3248                            .with_label(*prev, "previously listed here"),
3249                        );
3250                        continue;
3251                    }
3252                    within.insert(n.name.clone(), n.span);
3253
3254                    if !local.types.contains_key(&n.name) {
3255                        errors.push_for(Some(&parsed[i].identity_path()),
3256                            CompileError::new(
3257                                "bynk.exports.undeclared_type",
3258                                n.span,
3259                                format!(
3260                                    "exports clause references `{}`, which is not a type declared in context `{}`",
3261                                    n.name, name
3262                                ),
3263                            )
3264                            .with_note(
3265                                "only types declared in the same context can appear in `exports` clauses",
3266                            ),
3267                        );
3268                        continue;
3269                    }
3270                    // v0.25: `exports opaque/transparent { T }` names the type.
3271                    refs.record(n.span, SymbolKind::Type, &n.name);
3272
3273                    if let Some((prev_vis, prev_span)) = seen.get(&n.name) {
3274                        if *prev_vis == clause_vis {
3275                            errors.push_for(
3276                                Some(&parsed[i].identity_path()),
3277                                CompileError::new(
3278                                    "bynk.exports.duplicate_export",
3279                                    n.span,
3280                                    format!("type `{}` is exported more than once", n.name),
3281                                )
3282                                .with_label(*prev_span, "previously exported here"),
3283                            );
3284                        } else {
3285                            errors.push_for(Some(&parsed[i].identity_path()),
3286                                CompileError::new(
3287                                    "bynk.exports.conflicting_visibility",
3288                                    n.span,
3289                                    format!(
3290                                        "type `{}` is exported with conflicting visibilities — pick `opaque` or `transparent`",
3291                                        n.name,
3292                                    ),
3293                                )
3294                                .with_label(*prev_span, "previously exported here"),
3295                            );
3296                        }
3297                        continue;
3298                    }
3299                    seen.insert(n.name.clone(), (clause_vis, n.span));
3300                }
3301            }
3302        }
3303        let mut visibility_map: HashMap<String, Visibility> = HashMap::new();
3304        for (n, (v, _)) in seen {
3305            visibility_map.insert(n, v);
3306        }
3307        exports_visibility.insert(name.clone(), visibility_map);
3308    }
3309    exports_visibility
3310}
3311
3312/// Phase 6b': validate each context/adapter's `exports capability { … }` clauses
3313/// (v0.15 §4.1) — every name must be a capability the unit declares *and*
3314/// provides, with no duplicate exports. Diagnostics go into `errors` and export
3315/// references into `refs`.
3316pub fn phase_validate_capability_exports(
3317    groups: &BTreeMap<String, Vec<usize>>,
3318    kinds: &BTreeMap<String, UnitKind>,
3319    parsed: &[ParsedFile],
3320    unit_tables: &HashMap<String, UnitTable>,
3321    errors: &mut ErrorSink,
3322    refs: &mut RefSink,
3323) {
3324    for (name, indices) in groups {
3325        if kinds.get(name) != Some(&UnitKind::Context)
3326            && kinds.get(name) != Some(&UnitKind::Adapter)
3327        {
3328            continue;
3329        }
3330        let local = unit_tables.get(name).unwrap();
3331        let mut seen: HashMap<String, Span> = HashMap::new();
3332        for &i in indices {
3333            refs.enter_file(&parsed[i].identity_path(), name, parsed[i].is_synthetic());
3334            for clause in parsed[i].exports() {
3335                if !matches!(clause.kind, ExportKind::Capability) {
3336                    continue;
3337                }
3338                for n in clause.names.iter().map(|e| &e.name) {
3339                    if let Some(prev) = seen.get(&n.name) {
3340                        errors.push_for(
3341                            Some(&parsed[i].identity_path()),
3342                            CompileError::new(
3343                                "bynk.exports.duplicate_export",
3344                                n.span,
3345                                format!("capability `{}` is exported more than once", n.name),
3346                            )
3347                            .with_label(*prev, "previously exported here"),
3348                        );
3349                        continue;
3350                    }
3351                    seen.insert(n.name.clone(), n.span);
3352                    if local.capabilities.contains_key(&n.name) {
3353                        // v0.25: `exports capability { Cap }` names the
3354                        // capability.
3355                        refs.record(n.span, SymbolKind::Capability, &n.name);
3356                    }
3357                    if !local.capabilities.contains_key(&n.name) {
3358                        errors.push_for(Some(&parsed[i].identity_path()),
3359                            CompileError::new(
3360                                "bynk.exports.undeclared_capability",
3361                                n.span,
3362                                format!(
3363                                    "`exports capability` references `{}`, which is not a capability declared in context `{}`",
3364                                    n.name, name
3365                                ),
3366                            )
3367                            .with_note(
3368                                "only capabilities declared in the same context can appear in `exports capability` clauses",
3369                            ),
3370                        );
3371                        continue;
3372                    }
3373                    // S4a (#1966): a derived capability is refused as an export
3374                    // in its own right (`bynk.exports.derived_capability`); its
3375                    // provision is implied, so "no provider" would mislead.
3376                    let derived = local
3377                        .capabilities
3378                        .get(&n.name)
3379                        .is_some_and(|c| c.derived.is_some());
3380                    if !derived && !local.providers.contains_key(&n.name) {
3381                        errors.push_for(Some(&parsed[i].identity_path()),
3382                            CompileError::new(
3383                                "bynk.exports.capability_not_provided",
3384                                n.span,
3385                                format!(
3386                                    "exported capability `{}` has no provider in context `{}` — a consumer cannot instantiate it",
3387                                    n.name, name
3388                                ),
3389                            )
3390                            .with_note(format!(
3391                                "add a `provides {} = …` declaration so the capability can be wired into consumers",
3392                                n.name
3393                            )),
3394                        );
3395                    }
3396                }
3397            }
3398        }
3399    }
3400}
3401
3402/// Phase 6c: validate that every (non-external) provider matches its capability
3403/// exactly — each capability op has a provider op, and every provider op has a
3404/// matching capability op with the same parameter and return types. Diagnostics
3405/// go into `errors`.
3406pub fn phase_validate_providers(
3407    unit_tables: &HashMap<String, UnitTable>,
3408    // #696: the merged `UnitTable` has flattened a unit's files away, so provider
3409    // diagnostics need the group's files to recover which one declares each
3410    // provider and attribute the diagnostic to it.
3411    groups: &BTreeMap<String, Vec<usize>>,
3412    parsed: &[ParsedFile],
3413    // #1710: each unit's declarations recovery skipped (`phase_parse`).
3414    broken: &BrokenDeclNames,
3415    errors: &mut ErrorSink,
3416    tys: &Arc<Types>,
3417) {
3418    for (name, table) in unit_tables {
3419        // Map each provided capability to the project-relative path of the file
3420        // that declares its provider — every diagnostic below carries a span into
3421        // that file.
3422        let provider_files: HashMap<&str, PathBuf> = groups
3423            .get(name)
3424            .map(|indices| {
3425                indices
3426                    .iter()
3427                    .flat_map(|&i| {
3428                        parsed[i].items().iter().filter_map(move |item| match item {
3429                            CommonsItem::Provider(p) => {
3430                                Some((p.capability.name.as_str(), parsed[i].identity_path()))
3431                            }
3432                            _ => None,
3433                        })
3434                    })
3435                    .collect()
3436            })
3437            .unwrap_or_default();
3438        for (cap_name, provider) in &table.providers {
3439            let provider_file = provider_files.get(cap_name.as_str()).map(|p| p.as_path());
3440            // v0.17: an external provider has no Bynk body to match against the
3441            // capability — its implementation is the binding, checked by `tsc`.
3442            if provider.external {
3443                continue;
3444            }
3445            let Some(cap) = table.capabilities.get(cap_name) else {
3446                // #1710: a capability recovery skipped is a known name; its
3447                // syntax error is the report, and the provider has nothing to
3448                // be matched against until it's fixed.
3449                if broken.get(name).is_some_and(|b| b.contains(cap_name)) {
3450                    continue;
3451                }
3452                errors.push_for(provider_file,
3453                    CompileError::new(
3454                        "bynk.provider.unknown_capability",
3455                        provider.capability.span,
3456                        format!(
3457                            "provider targets unknown capability `{}` — declare the capability in the same context",
3458                            cap_name
3459                        ),
3460                    ),
3461                );
3462                continue;
3463            };
3464            // #926 (Decision E): a capability op with its own type parameter(s)
3465            // cannot be implemented by a Bynk-bodied provider — the body would
3466            // need `T` rigid through the handler-body checker for a body that
3467            // can only ever return `None` or echo a `T`-typed parameter.
3468            // External providers (checked above) are exempt: TypeScript
3469            // natively supports a generic interface method, so a hand-authored
3470            // binding class implements it directly.
3471            for cap_op in &cap.ops {
3472                if !cap_op.type_params.is_empty() {
3473                    errors.push_for(
3474                        provider_file,
3475                        CompileError::new(
3476                            "bynk.provider.generic_op_requires_external",
3477                            provider.span,
3478                            format!(
3479                                "provider `{}` for capability `{}` has a Bynk body, but operation `{}` declares its own type parameter(s) (`[{}]`) — a generic capability operation requires an external (bodiless) provider",
3480                                provider.provider_name.name,
3481                                cap_name,
3482                                cap_op.name.name,
3483                                cap_op
3484                                    .type_params
3485                                    .iter()
3486                                    .map(|p| p.name.name.as_str())
3487                                    .collect::<Vec<_>>()
3488                                    .join(", "),
3489                            ),
3490                        )
3491                        .with_note(
3492                            "write `provides Cap = Name` with no `{ … }` block, and supply the implementation as a hand-authored class in the adapter's binding file",
3493                        ),
3494                    );
3495                }
3496            }
3497            // 1) Every capability op has a provider op.
3498            for cap_op in &cap.ops {
3499                if !provider.ops.iter().any(|o| o.name.name == cap_op.name.name) {
3500                    errors.push_for(
3501                        provider_file,
3502                        CompileError::new(
3503                            "bynk.provider.missing_operation",
3504                            provider.span,
3505                            format!(
3506                                "provider `{}` for capability `{}` is missing operation `{}`",
3507                                provider.provider_name.name, cap_name, cap_op.name.name
3508                            ),
3509                        ),
3510                    );
3511                }
3512            }
3513            // 2) Every provider op corresponds to a capability op with the
3514            //    same signature (param types and return type).
3515            for prov_op in &provider.ops {
3516                let Some(cap_op) = cap.ops.iter().find(|o| o.name.name == prov_op.name.name) else {
3517                    errors.push_for(provider_file, CompileError::new(
3518                        "bynk.provider.extra_operation",
3519                        prov_op.span,
3520                        format!(
3521                            "provider operation `{}.{}` does not match any operation in capability `{}`",
3522                            provider.provider_name.name, prov_op.name.name, cap_name
3523                        ),
3524                    ));
3525                    continue;
3526                };
3527                if cap_op.params.len() != prov_op.params.len() {
3528                    errors.push_for(provider_file, CompileError::new(
3529                        "bynk.provider.signature_mismatch",
3530                        prov_op.span,
3531                        format!(
3532                            "provider operation `{}.{}` has {} parameter(s), but capability operation expects {}",
3533                            provider.provider_name.name,
3534                            prov_op.name.name,
3535                            prov_op.params.len(),
3536                            cap_op.params.len()
3537                        ),
3538                    ));
3539                    continue;
3540                }
3541                // Resolved-`Ty` equality, not surface-syntax comparison: two
3542                // signatures that spell a type differently (an alias, or a
3543                // generic application written out) but resolve to the same
3544                // `Ty` must not be flagged as a mismatch, and — the bug this
3545                // replaces — a `TypeRef` shape `type_refs_match` didn't cover
3546                // (List/Map/Query/Stream/Connection/…) must not be silently
3547                // treated as *matching* just because it fell through to
3548                // `_ => false` on both sides of an `!`. A Bynk-bodied
3549                // provider op has no type params of its own (checked above),
3550                // so its params/return type resolve with no vars in scope.
3551                let cap_info = build_capability_op_info(cap_op, &table.types, tys);
3552                let no_vars = HashSet::new();
3553                let prov_params: Vec<TyId> = prov_op
3554                    .params
3555                    .iter()
3556                    .map(|p| {
3557                        checker::resolve_type_ref_in(&p.type_ref, &table.types, &no_vars, tys)
3558                            .unwrap_or(tys.intern(Ty::Unit))
3559                    })
3560                    .collect();
3561                let prov_return_ty =
3562                    checker::resolve_type_ref_in(&prov_op.return_type, &table.types, &no_vars, tys)
3563                        .unwrap_or(tys.intern(Ty::Unit));
3564                for (i, (cap_ty, (prov_p, prov_ty))) in cap_info
3565                    .params
3566                    .iter()
3567                    .zip(prov_op.params.iter().zip(prov_params.iter()))
3568                    .enumerate()
3569                {
3570                    if cap_ty != prov_ty {
3571                        errors.push_for(provider_file, CompileError::new(
3572                            "bynk.provider.signature_mismatch",
3573                            prov_p.span,
3574                            format!(
3575                                "provider operation `{}.{}` parameter {} has type `{}`, but capability declares `{}`",
3576                                provider.provider_name.name,
3577                                prov_op.name.name,
3578                                i + 1,
3579                                ts_type_ref_display(&prov_p.type_ref),
3580                                ts_type_ref_display(&cap_op.params[i].type_ref)
3581                            ),
3582                        ));
3583                    }
3584                }
3585                if cap_info.return_ty != prov_return_ty {
3586                    errors.push_for(provider_file, CompileError::new(
3587                        "bynk.provider.signature_mismatch",
3588                        prov_op.return_type.span(),
3589                        format!(
3590                            "provider operation `{}.{}` returns `{}`, but capability declares `{}`",
3591                            provider.provider_name.name,
3592                            prov_op.name.name,
3593                            ts_type_ref_display(&prov_op.return_type),
3594                            ts_type_ref_display(&cap_op.return_type)
3595                        ),
3596                    ));
3597                }
3598            }
3599        }
3600    }
3601}
3602
3603/// v0.19: the lock violation a deployment unit's native-platform set implies
3604/// under the selected `--platform`, if any. Pure — unit-tested below with
3605/// synthetic sets (the conflict arm is not yet reachable end-to-end while
3606/// only one platform ships native capabilities).
3607///
3608/// P5.3 (`design/tracks/semantics-in-the-checker.md` §6): relocated
3609/// verbatim from `bynk-emit/src/project/validate.rs`, alongside
3610/// [`phase_platform_lock`].
3611fn lock_violation(
3612    native: &BTreeMap<Platform, String>,
3613    selected: Platform,
3614) -> Option<LockViolation> {
3615    let mut platforms = native.iter();
3616    let (first, first_unit) = platforms.next()?;
3617    if let Some((second, second_unit)) = platforms.next() {
3618        return Some(LockViolation::Conflict {
3619            a: (*first, first_unit.clone()),
3620            b: (*second, second_unit.clone()),
3621        });
3622    }
3623    if *first != selected {
3624        return Some(LockViolation::Required {
3625            needed: *first,
3626            unit: first_unit.clone(),
3627        });
3628    }
3629    None
3630}
3631
3632/// A platform-lock violation (v0.19, `bynk.target.*`).
3633#[derive(Debug, PartialEq, Eq)]
3634enum LockViolation {
3635    /// The deployment unit needs `needed` but another platform is selected.
3636    Required { needed: Platform, unit: String },
3637    /// The deployment unit's closure spans two mutually-exclusive platforms.
3638    Conflict {
3639        a: (Platform, String),
3640        b: (Platform, String),
3641    },
3642}
3643
3644/// v0.15's cross-context capability resolution, relocated alongside
3645/// [`phase_platform_lock`] (P5.3): resolve a `given`/handler capability
3646/// prefix (`ctx.Cap`) against a context's own `consumes`/alias tables. Pure —
3647/// no codegen, no `bynk-emit` dependency of its own — so unlike
3648/// `collect_given_closure` this one **is** shared rather than duplicated:
3649/// `bynk-emit/src/project.rs`'s own copy of this function (and of
3650/// [`handler_cross_caps`]) was deleted in review (#1133) and every one of its
3651/// call sites repointed here — `bynk-emit` already depends on `bynk-check`,
3652/// so there was no dependency direction to route around, and keeping two
3653/// copies only bought two things that could drift out of sync for no reason.
3654pub fn resolve_consume_prefix(
3655    prefix: &str,
3656    consumed: &[String],
3657    aliases: &HashMap<String, String>,
3658) -> Option<String> {
3659    if let Some(q) = aliases.get(prefix) {
3660        return Some(q.clone());
3661    }
3662    if consumed.iter().any(|c| c == prefix) {
3663        return Some(prefix.to_string());
3664    }
3665    None
3666}
3667
3668/// v0.15: the cross-context capabilities a context's **handlers** reference,
3669/// as `deps_key → consumed_context`. Shared with `bynk-emit`, not duplicated
3670/// — see [`resolve_consume_prefix`]'s doc.
3671pub fn handler_cross_caps(
3672    table: &UnitTable,
3673    consumed: &[String],
3674    aliases: &HashMap<String, String>,
3675    flattened: &HashMap<String, String>,
3676) -> BTreeMap<String, String> {
3677    let mut out = BTreeMap::new();
3678    let mut scan = |given: &[CapRef]| {
3679        for c in given {
3680            // Events track, slice 0 (spine #936): `Events.emit` is
3681            // intercepted entirely at the call site (release-at-commit
3682            // buffering) and never calls through a constructed provider —
3683            // there is no `EventsProvider` for compose to build, so the
3684            // first-party `Events` must never become a compose deps entry.
3685            if c.key() == "Events" && flattened.get(c.key()).map(String::as_str) == Some("bynk") {
3686                continue;
3687            }
3688            if let Some(p) = c.prefix() {
3689                if let Some(ctx) = resolve_consume_prefix(&p, consumed, aliases) {
3690                    out.entry(c.key().to_string()).or_insert(ctx);
3691                }
3692            } else if let Some(unit) = flattened.get(c.key()) {
3693                // v0.17: a bare flattened capability is provided by the unit it
3694                // was flattened from.
3695                out.entry(c.key().to_string())
3696                    .or_insert_with(|| unit.clone());
3697            }
3698        }
3699    };
3700    for s in table.services.values() {
3701        for h in &s.handlers {
3702            scan(&h.given);
3703        }
3704    }
3705    for a in table.agents.values() {
3706        for h in &a.handlers {
3707            scan(&h.given);
3708        }
3709    }
3710    out
3711}
3712
3713/// The `(unit, capability)` pairs a provider capability's `given` closure
3714/// transitively reaches, itself included, recorded into `reached`. P5.3: a pure resolution walk over the
3715/// same graph `bynk-emit`'s `instantiate_provider_ts_expr` walks to build a
3716/// TypeScript instantiation expression — this one builds no TypeScript at
3717/// all, since `bynk-check` must never depend on `bynk-emit`'s codegen
3718/// (`bynk-emit` depends on `bynk-check`, never the reverse). The two walks
3719/// must keep resolving `given` targets identically (prefix → alias/consumes,
3720/// bare → flattened) or `phase_platform_lock`'s native-platform accounting
3721/// could drift from what a real build's compose actually instantiates; a
3722/// reviewer changing one should check the other.
3723fn collect_given_closure(
3724    provider_ctx: &str,
3725    cap: &str,
3726    unit_tables: &HashMap<String, UnitTable>,
3727    unit_consumes: &HashMap<String, Vec<String>>,
3728    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3729    unit_flattened: &HashMap<String, HashMap<String, String>>,
3730    reached: &mut BTreeSet<(String, String)>,
3731) {
3732    // #2355 follow-up: records `(unit, capability)` pairs rather than units,
3733    // so the cross-context agent check can find the provider itself; a pair
3734    // already seen is not walked again.
3735    if !reached.insert((provider_ctx.to_string(), cap.to_string())) {
3736        return;
3737    }
3738    let Some(provider) = unit_tables
3739        .get(provider_ctx)
3740        .and_then(|t| t.providers.get(cap))
3741    else {
3742        return;
3743    };
3744    if provider.given.is_empty() {
3745        return;
3746    }
3747    let consumed = unit_consumes.get(provider_ctx).cloned().unwrap_or_default();
3748    let aliases = unit_consumes_aliases
3749        .get(provider_ctx)
3750        .cloned()
3751        .unwrap_or_default();
3752    let flattened = unit_flattened
3753        .get(provider_ctx)
3754        .cloned()
3755        .unwrap_or_default();
3756    for g in &provider.given {
3757        let target_ctx = match g.prefix() {
3758            Some(p) => resolve_consume_prefix(&p, &consumed, &aliases)
3759                .unwrap_or_else(|| provider_ctx.to_string()),
3760            None => flattened
3761                .get(g.key())
3762                .cloned()
3763                .unwrap_or_else(|| provider_ctx.to_string()),
3764        };
3765        collect_given_closure(
3766            &target_ctx,
3767            g.key(),
3768            unit_tables,
3769            unit_consumes,
3770            unit_consumes_aliases,
3771            unit_flattened,
3772            reached,
3773        );
3774    }
3775}
3776
3777/// v0.19 (decision 0017): the native platforms a context's **in-process
3778/// closure** commits it to: every unit whose provider its compose would
3779/// instantiate — local providers' `given` recursion plus the capabilities its
3780/// handlers reference — mapped through [`firstparty::platform_of`]. Each
3781/// platform carries an exemplar unit for the diagnostic message. Service
3782/// `consumes` edges (RPC under `workers`) do not contribute — only the
3783/// provider-instantiation walk, which is in-process by construction.
3784///
3785/// P5.3: relocated alongside [`phase_platform_lock`], reimplemented on
3786/// [`collect_given_closure`] rather than moved verbatim — see that
3787/// function's doc.
3788fn native_platforms_of_context(
3789    ctx: &str,
3790    table: &UnitTable,
3791    unit_tables: &HashMap<String, UnitTable>,
3792    unit_consumes: &HashMap<String, Vec<String>>,
3793    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3794    unit_flattened: &HashMap<String, HashMap<String, String>>,
3795) -> BTreeMap<Platform, String> {
3796    let mut reached: BTreeSet<(String, String)> = BTreeSet::new();
3797    for cap in table.providers.keys() {
3798        collect_given_closure(
3799            ctx,
3800            cap,
3801            unit_tables,
3802            unit_consumes,
3803            unit_consumes_aliases,
3804            unit_flattened,
3805            &mut reached,
3806        );
3807    }
3808    let consumed = unit_consumes.get(ctx).cloned().unwrap_or_default();
3809    let aliases = unit_consumes_aliases.get(ctx).cloned().unwrap_or_default();
3810    let flattened = unit_flattened.get(ctx).cloned().unwrap_or_default();
3811    for (key, cctx) in handler_cross_caps(table, &consumed, &aliases, &flattened) {
3812        collect_given_closure(
3813            &cctx,
3814            &key,
3815            unit_tables,
3816            unit_consumes,
3817            unit_consumes_aliases,
3818            unit_flattened,
3819            &mut reached,
3820        );
3821    }
3822    let mut referenced: BTreeSet<String> = reached.into_iter().map(|(unit, _)| unit).collect();
3823    // S4a (#1966): a derived capability reaches its base's platform unit, so
3824    // a context whose only Cloudflare use is a named instance is still locked.
3825    referenced.extend(table.derived.values().map(|d| d.base_unit.clone()));
3826    let mut out = BTreeMap::new();
3827    for unit in referenced {
3828        if let Some(p) = firstparty::platform_of(&unit) {
3829            out.entry(p).or_insert(unit);
3830        }
3831    }
3832    out
3833}
3834
3835/// v0.19 (decisions 0017/0024): enforce the platform lock per deployment
3836/// unit — each context under `--target workers`, the whole program under
3837/// `bundle` (co-location shares the lock).
3838///
3839/// P5.3 (`design/tracks/semantics-in-the-checker.md` §6): relocated from
3840/// `bynk-emit/src/project/validate.rs`'s `check_platform_lock` — category 5
3841/// of `analysis.rs`'s own seven-category residual-gap accounting ("gap in
3842/// name only": `analyse_project` hardcodes `Platform::default()`
3843/// (Cloudflare) and `BuildTarget::Bundle`, and `bynk.cloudflare` is the only
3844/// platform-native unit that exists, so `lock_violation` can never fire on
3845/// that path regardless of where this function lives — see `analysis.rs`'s
3846/// own doc for why R3.5 still requires the move).
3847#[allow(clippy::too_many_arguments)]
3848pub fn phase_platform_lock(
3849    target: BuildTarget,
3850    selected: Platform,
3851    parsed: &[ParsedFile],
3852    groups: &BTreeMap<String, Vec<usize>>,
3853    kinds: &BTreeMap<String, UnitKind>,
3854    unit_tables: &HashMap<String, UnitTable>,
3855    unit_consumes: &HashMap<String, Vec<String>>,
3856    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3857    unit_flattened: &HashMap<String, HashMap<String, String>>,
3858    errors: &mut ErrorSink,
3859) {
3860    // In-browser track, slice 2: `browser` is a Bundle-only platform — a browser
3861    // cannot do the Workers wire-call model (Service Bindings, Durable Objects,
3862    // cross-context wire calls). Reject the combination up front, before the
3863    // per-unit native-platform lock below, which is moot for an invalid build.
3864    if selected == Platform::Browser && target == BuildTarget::Workers {
3865        errors.push_for(
3866            None,
3867            CompileError::new(
3868                "bynk.target.browser_bundle_only",
3869                Span::default(),
3870                "`--platform browser` builds only the in-process `Bundle` topology, but `--target workers` was selected; a browser cannot run the Workers wire-call model",
3871            )
3872            .with_note("build the browser target with `--target bundle` (the default)"),
3873        );
3874        return;
3875    }
3876    // v0.104 (real-time track slice 3b): the `from websocket` Workers mapping (the
3877    // Durable Object hibernatable upgrade) is now emitted, so the 3a platform-lock
3878    // that gated it off is removed.
3879    // Per-context native sets, with the context name kept for spans/messages.
3880    let mut per_context: Vec<(String, BTreeMap<Platform, String>)> = Vec::new();
3881    let mut names: Vec<&String> = groups.keys().collect();
3882    names.sort();
3883    for name in names {
3884        if kinds.get(name.as_str()) != Some(&UnitKind::Context) {
3885            continue;
3886        }
3887        let Some(table) = unit_tables.get(name.as_str()) else {
3888            continue;
3889        };
3890        let native = native_platforms_of_context(
3891            name,
3892            table,
3893            unit_tables,
3894            unit_consumes,
3895            unit_consumes_aliases,
3896            unit_flattened,
3897        );
3898        if !native.is_empty() {
3899            per_context.push((name.clone(), native));
3900        }
3901    }
3902    // The deployment units to check: per-context under workers; their union
3903    // under bundle (the whole program co-locates).
3904    let units: Vec<(String, BTreeMap<Platform, String>)> = match target {
3905        BuildTarget::Workers => per_context,
3906        BuildTarget::Bundle => {
3907            let mut union = BTreeMap::new();
3908            let mut owner: Option<String> = None;
3909            for (ctx, native) in per_context {
3910                owner.get_or_insert(ctx);
3911                for (p, unit) in native {
3912                    union.entry(p).or_insert(unit);
3913                }
3914            }
3915            match owner {
3916                Some(ctx) if !union.is_empty() => vec![(ctx, union)],
3917                _ => Vec::new(),
3918            }
3919        }
3920    };
3921    for (ctx, native) in units {
3922        let Some(violation) = lock_violation(&native, selected) else {
3923            continue;
3924        };
3925        let span_for = |unit: &str| {
3926            groups
3927                .get(&ctx)
3928                .and_then(|idx| consumes_span_of(parsed, idx, unit))
3929                .map(|(_, s)| s)
3930                .unwrap_or_default()
3931        };
3932        match violation {
3933            LockViolation::Required { needed, unit } => {
3934                errors.push_for(
3935                    None,
3936                    CompileError::new(
3937                        "bynk.target.vendor_required",
3938                        span_for(&unit),
3939                        format!(
3940                            "context `{ctx}` uses the platform-native capabilities of `{unit}`, which run only on the `{}` platform, but the build selects `--platform {}`",
3941                            needed.as_str(),
3942                            selected.as_str(),
3943                        ),
3944                    )
3945                    .with_note(
3946                        "build with the matching `--platform`, or remove the platform-native dependency to stay portable",
3947                    ),
3948                );
3949            }
3950            LockViolation::Conflict { a, b } => {
3951                errors.push_for(
3952                    None,
3953                    CompileError::new(
3954                        "bynk.target.vendor_conflict",
3955                        span_for(&a.1),
3956                        format!(
3957                            "one deployment unit (via context `{ctx}`) uses platform-native capabilities from two mutually-exclusive platforms: `{}` (from `{}`) and `{}` (from `{}`)",
3958                            a.0.as_str(),
3959                            a.1,
3960                            b.0.as_str(),
3961                            b.1,
3962                        ),
3963                    )
3964                    .with_note(
3965                        "split the consumers into separate deployment units (`--target workers`), or remove one of the platform-native dependencies",
3966                    ),
3967                );
3968            }
3969        }
3970    }
3971}
3972
3973/// v0.173 (ADR 0196 D1), P5.5 (`design/tracks/semantics-in-the-checker.md`
3974/// §6, §9): warn where a `bynk.Secrets` read names its secret with a computed
3975/// expression. Non-failing — the program is correct, `bynk deploy` simply
3976/// cannot see the name — walked per **file** rather than per unit, since a
3977/// merged `UnitTable` has thrown away which file a call site lives in and
3978/// [`ErrorSink::extend_for`] attributes a diagnostic to a path.
3979///
3980/// Gated on the Workers target because the whole consequence is about `bynk
3981/// deploy`'s plan, which no other target produces; warning a bundle project
3982/// about a deploy plan it will never produce would be noise. Relocated from
3983/// `bynk-emit::project::run_checks` — that call site's own comment claimed
3984/// this "reaches the editor" via `bynk check`/the LSP, which was true only
3985/// while the LSP still called `run_checks`'s `Mode::Analyse` arm; P4.2
3986/// repointed `bynk-ide` at [`crate::analysis::analyse_project`] instead, and
3987/// `bynk-check` cannot depend on `bynk-emit` to reach this code — so the
3988/// claim went stale silently, exactly the "ninth gap" §9 of the design doc
3989/// flagged as a risk rather than a scoped relocation. Wired into
3990/// `analyse_project` at the same relative point `run_checks` calls it,
3991/// mirroring [`phase_platform_lock`]'s own treatment of a build-target-gated
3992/// check: `analyse_project` hardcodes `BuildTarget::Bundle`, so this closes
3993/// the category structurally (R3.5 — the diagnostic now originates in
3994/// `bynk-check`), not observably, the same as categories 1 and 5.
3995pub fn phase_secrets_computed_name(
3996    target: BuildTarget,
3997    parsed: &[ParsedFile],
3998    groups: &BTreeMap<String, Vec<usize>>,
3999    kinds: &BTreeMap<String, UnitKind>,
4000    unit_flattened: &HashMap<String, HashMap<String, String>>,
4001    errors: &mut ErrorSink,
4002) {
4003    if target != BuildTarget::Workers {
4004        return;
4005    }
4006    for (name, indices) in groups {
4007        if kinds.get(name) != Some(&UnitKind::Context) {
4008            continue;
4009        }
4010        let Some(flattened) = unit_flattened.get(name) else {
4011            continue;
4012        };
4013        for &i in indices {
4014            let SourceUnit::Context(ctx) = &parsed[i].unit() else {
4015                continue;
4016            };
4017            // #1976: agent handlers and provider bodies are this Worker's
4018            // reads too, so a computed name in one is warned like any other.
4019            let items = &ctx.items;
4020            let handlers = items.iter().filter_map(|item| match item {
4021                CommonsItem::Service(s) => Some(s.handlers.iter()),
4022                CommonsItem::Agent(a) => Some(a.handlers.iter()),
4023                _ => None,
4024            });
4025            let providers = items.iter().filter_map(|item| match item {
4026                CommonsItem::Provider(p) => Some(p),
4027                _ => None,
4028            });
4029            let (_, mut warnings) = crate::secrets::secret_reads_of(handlers.flatten(), flattened);
4030            warnings.extend(crate::secrets::provider_secret_reads_of(providers, flattened).1);
4031            let rel = parsed[i].identity_path();
4032            errors.extend_for(Some(&rel), warnings);
4033        }
4034    }
4035}
4036
4037/// #1822: on the Workers target, a capability a context declares and requires
4038/// (`given`, on a service or agent handler or a provider) needs a provider in
4039/// that context. A Worker's composition root builds every capability its
4040/// handlers are given, so one with no provider left `compose` passing `{}`
4041/// where the capability was required (TS2345): the bundle built and the
4042/// Workers build didn't. On the bundle target the host may supply it through
4043/// `__makeSurface(deps)`, so the check is Workers-only, like
4044/// [`phase_secrets_computed_name`]. A capability from another unit (flattened
4045/// or qualified) is that unit's to provide, and `exports capability` already
4046/// requires its provider (`bynk.exports.capability_not_provided`).
4047pub fn phase_workers_unprovided_capabilities(
4048    target: BuildTarget,
4049    parsed: &[ParsedFile],
4050    groups: &BTreeMap<String, Vec<usize>>,
4051    kinds: &BTreeMap<String, UnitKind>,
4052    unit_tables: &HashMap<String, UnitTable>,
4053    errors: &mut ErrorSink,
4054) {
4055    if target != BuildTarget::Workers {
4056        return;
4057    }
4058    for (name, indices) in groups {
4059        if kinds.get(name) != Some(&UnitKind::Context) {
4060            continue;
4061        }
4062        let Some(table) = unit_tables.get(name) else {
4063            continue;
4064        };
4065        // #1882 review: once per capability, at its first `given`, however
4066        // many handlers require it.
4067        let mut reported: HashSet<String> = HashSet::new();
4068        for &i in indices {
4069            let SourceUnit::Context(ctx) = &parsed[i].unit() else {
4070                continue;
4071            };
4072            let mut givens: Vec<&CapRef> = Vec::new();
4073            for item in &ctx.items {
4074                match item {
4075                    CommonsItem::Service(s) => {
4076                        givens.extend(s.handlers.iter().flat_map(|h| &h.given));
4077                    }
4078                    CommonsItem::Agent(a) => {
4079                        givens.extend(a.handlers.iter().flat_map(|h| &h.given));
4080                    }
4081                    CommonsItem::Provider(p) => givens.extend(&p.given),
4082                    _ => {}
4083                }
4084            }
4085            for cap in givens {
4086                // An exported capability with no provider is already
4087                // `bynk.exports.capability_not_provided` (#1882 review: no
4088                // second error for the same gap).
4089                if cap.context.is_some()
4090                    || !table.capabilities.contains_key(&cap.name.name)
4091                    || table.providers.contains_key(&cap.name.name)
4092                    // S4a (#1966): a derived capability's provision is implied.
4093                    || table.derived.contains_key(&cap.name.name)
4094                    || table.exported_capabilities.contains(&cap.name.name)
4095                    || !reported.insert(cap.name.name.clone())
4096                {
4097                    continue;
4098                }
4099                errors.push_for(
4100                    Some(&parsed[i].identity_path()),
4101                    CompileError::new(
4102                        "bynk.capability.not_provided",
4103                        cap.span,
4104                        format!(
4105                            "capability `{}` is required here but has no provider in context `{name}`; a Worker builds every capability its handlers are given",
4106                            cap.name.name
4107                        ),
4108                    )
4109                    .with_note(format!(
4110                        "add `provides {} = …` to `{name}`, or build for `--target bundle`, where the host supplies it",
4111                        cap.name.name
4112                    )),
4113                );
4114            }
4115        }
4116    }
4117}
4118
4119/// #2306: on the Workers target, a `from websocket` service's `on open` is hosted
4120/// inside the Durable Object of the agent it transfers the connection to, and
4121/// that method is emitted from the file the *service* and agent share. A context
4122/// split across several files is emitted one file at a time, so a transfer into
4123/// an agent declared in a different file than the service was routed to the
4124/// agent's Durable Object (`compose.ts` forwards the upgrade) but never hosted
4125/// there: the upgrade returned `404`. Reject it at compile time instead. On the
4126/// bundle target the open is an ordinary surface method and the layout works, so
4127/// the check is Workers-only, like [`phase_workers_unprovided_capabilities`].
4128pub fn phase_workers_ws_open_split(
4129    target: BuildTarget,
4130    parsed: &[ParsedFile],
4131    groups: &BTreeMap<String, Vec<usize>>,
4132    kinds: &BTreeMap<String, UnitKind>,
4133    unit_tables: &HashMap<String, UnitTable>,
4134    errors: &mut ErrorSink,
4135) {
4136    if target != BuildTarget::Workers {
4137        return;
4138    }
4139    for (name, indices) in groups {
4140        if kinds.get(name) != Some(&UnitKind::Context) || indices.len() < 2 {
4141            continue;
4142        }
4143        let Some(table) = unit_tables.get(name) else {
4144            continue;
4145        };
4146        let local_agents: HashSet<String> = table.agents.keys().cloned().collect();
4147        for &i in indices {
4148            let SourceUnit::Context(ctx) = &parsed[i].unit() else {
4149                continue;
4150            };
4151            for item in &ctx.items {
4152                let CommonsItem::Service(service) = item else {
4153                    continue;
4154                };
4155                if !matches!(service.protocol, ServiceProtocol::WebSocket { .. }) {
4156                    continue;
4157                }
4158                for open in service
4159                    .handlers
4160                    .iter()
4161                    .filter(|h| matches!(h.kind, HandlerKind::Open))
4162                {
4163                    let crate::websocket::WsOpenShape::One(target_agent) =
4164                        crate::websocket::analyse_open_shape(&open.body, &local_agents)
4165                    else {
4166                        continue; // a missing or ambiguous transfer has its own diagnostic
4167                    };
4168                    let declared_here = ctx.items.iter().any(|it| {
4169                        matches!(it, CommonsItem::Agent(a) if a.name.name == target_agent.agent)
4170                    });
4171                    if declared_here {
4172                        continue;
4173                    }
4174                    errors.push_for(
4175                        Some(&parsed[i].identity_path()),
4176                        CompileError::new(
4177                            "bynk.ws.open_transfer_cross_file",
4178                            open.span,
4179                            format!(
4180                                "this `on open` transfers its `connection` to agent `{}`, which is declared in a different file of context `{name}` than the service `{}`",
4181                                target_agent.agent, service.name.name
4182                            ),
4183                        )
4184                        .with_note(
4185                            "on Workers the open is hosted inside the agent's Durable Object, which is emitted from the file that declares the agent; move the service into the agent's file, or build for `--target bundle`",
4186                        ),
4187                    );
4188                }
4189            }
4190        }
4191    }
4192}
4193
4194/// #2355: whether a bodied provider's ops construct one of its context's own
4195/// agents (`is_agent`). Such a provider takes a `deps` even with no `given`:
4196/// its agent calls hand `this.deps` to the agent's methods, and on Workers the
4197/// agent factory reads the Worker's `env` from it. Syntactic, so the provider
4198/// class, every composer that builds it, and
4199/// [`phase_workers_provider_agent_cross_context`] agree on the answer; a local
4200/// shadowing an agent's name only costs an unused `deps`. It matches the
4201/// construction shape `Agent(key)`, the one the emitter's `agent_construct`
4202/// lowers; a new way to reach an agent must be added here too.
4203pub fn provider_constructs_agent(p: &ProviderDecl, is_agent: impl Fn(&str) -> bool) -> bool {
4204    fn walk(e: &Expr, is_agent: &dyn Fn(&str) -> bool) -> bool {
4205        if let ExprKind::Call { name, .. } = &e.kind
4206            && is_agent(&name.name)
4207        {
4208            return true;
4209        }
4210        expr_children(e).into_iter().any(|c| walk(c, is_agent))
4211    }
4212    p.ops.iter().any(|op| {
4213        let mut top = Vec::new();
4214        for s in &op.body.statements {
4215            statement_exprs(s, &mut top);
4216        }
4217        top.push(&op.body.tail);
4218        top.into_iter().any(|e| walk(e, &is_agent))
4219    })
4220}
4221
4222/// #2355 follow-up: on the Workers target, a context may not require a
4223/// capability whose provider, in another context, constructs that context's
4224/// agents. The consumer's composition root builds the provider in-process and
4225/// hands it the consumer's own `env`, which binds only the consumer's Durable
4226/// Object namespaces: `tsc` rejects the build, and strip-only `bynk dev`
4227/// quietly falls back to agent state held in the isolate. The capability can
4228/// be reached through any `given` the consumer's compose instantiates — a
4229/// service or agent handler's, or a local provider's, transitively — so the
4230/// walk is `collect_given_closure`'s, reported once per provider at the
4231/// first `given` that reaches it. On the bundle target the whole program
4232/// shares one process and the agent factory takes only the key, so the check
4233/// is Workers-only, like [`phase_workers_unprovided_capabilities`].
4234#[allow(clippy::too_many_arguments)]
4235pub fn phase_workers_provider_agent_cross_context(
4236    target: BuildTarget,
4237    parsed: &[ParsedFile],
4238    groups: &BTreeMap<String, Vec<usize>>,
4239    kinds: &BTreeMap<String, UnitKind>,
4240    unit_tables: &HashMap<String, UnitTable>,
4241    unit_consumes: &HashMap<String, Vec<String>>,
4242    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
4243    unit_flattened: &HashMap<String, HashMap<String, String>>,
4244    errors: &mut ErrorSink,
4245) {
4246    if target != BuildTarget::Workers {
4247        return;
4248    }
4249    for (name, indices) in groups {
4250        if kinds.get(name) != Some(&UnitKind::Context) {
4251            continue;
4252        }
4253        let consumed = unit_consumes.get(name).cloned().unwrap_or_default();
4254        let aliases = unit_consumes_aliases.get(name).cloned().unwrap_or_default();
4255        let flattened = unit_flattened.get(name).cloned().unwrap_or_default();
4256        let mut reported: HashSet<(String, String)> = HashSet::new();
4257        for &i in indices {
4258            let SourceUnit::Context(ctx) = &parsed[i].unit() else {
4259                continue;
4260            };
4261            let mut givens: Vec<&CapRef> = Vec::new();
4262            for item in &ctx.items {
4263                match item {
4264                    CommonsItem::Service(s) => {
4265                        givens.extend(s.handlers.iter().flat_map(|h| &h.given));
4266                    }
4267                    CommonsItem::Agent(a) => {
4268                        givens.extend(a.handlers.iter().flat_map(|h| &h.given));
4269                    }
4270                    CommonsItem::Provider(p) => givens.extend(&p.given),
4271                    _ => {}
4272                }
4273            }
4274            for cap in givens {
4275                // The same resolution as `collect_given_closure`'s.
4276                let target_ctx = match cap.prefix() {
4277                    Some(p) => resolve_consume_prefix(&p, &consumed, &aliases)
4278                        .unwrap_or_else(|| name.clone()),
4279                    None => flattened
4280                        .get(cap.key())
4281                        .cloned()
4282                        .unwrap_or_else(|| name.clone()),
4283                };
4284                let mut reached: BTreeSet<(String, String)> = BTreeSet::new();
4285                collect_given_closure(
4286                    &target_ctx,
4287                    cap.key(),
4288                    unit_tables,
4289                    unit_consumes,
4290                    unit_consumes_aliases,
4291                    unit_flattened,
4292                    &mut reached,
4293                );
4294                for (unit, key) in reached {
4295                    if unit == *name {
4296                        continue;
4297                    }
4298                    let Some(table) = unit_tables.get(&unit) else {
4299                        continue;
4300                    };
4301                    let Some(provider) = table.providers.get(&key) else {
4302                        continue;
4303                    };
4304                    if provider.external
4305                        || !provider_constructs_agent(provider, |n| table.agents.contains_key(n))
4306                        || !reported.insert((unit.clone(), key.clone()))
4307                    {
4308                        continue;
4309                    }
4310                    errors.push_for(
4311                        Some(&parsed[i].identity_path()),
4312                        CompileError::new(
4313                            "bynk.capability.provider_agent_cross_context",
4314                            cap.span,
4315                            format!(
4316                                "capability `{key}` is provided by `{}` in context `{unit}`, which constructs `{unit}`'s agents; on Workers context `{name}` would build that provider with its own `env`, which has no binding for those agents' Durable Objects",
4317                                provider.provider_name.name
4318                            ),
4319                        )
4320                        .with_note(format!(
4321                            "call a `{unit}` service that uses `{key}` instead, or build for `--target bundle`, where every context shares one process"
4322                        )),
4323                    );
4324                }
4325            }
4326        }
4327    }
4328}
4329
4330/// Phase 7: build each production unit's file-declaration index (which file in
4331/// the unit declares which name), for cross-file lookups in the back half.
4332pub fn phase_file_index(
4333    groups: &BTreeMap<String, Vec<usize>>,
4334    parsed: &[ParsedFile],
4335) -> HashMap<String, FileDeclIndex> {
4336    let mut unit_file_index: HashMap<String, FileDeclIndex> = HashMap::new();
4337    for (name, indices) in groups {
4338        unit_file_index.insert(name.clone(), build_file_decl_index(indices, parsed));
4339    }
4340    unit_file_index
4341}
4342
4343/// v0.29.4: the per-unit facets that the producer phases build as nine parallel
4344/// `HashMap<String, _>`s, all keyed on unit name. Assembling one record per unit
4345/// makes the "all these maps share one keyset" invariant structural: a single
4346/// lookup yields every facet as a field, so the per-column `.unwrap()`s on the
4347/// shared keyset disappear. Fields are total — `exports`/`aliases`/`flattened`
4348/// default to an empty map for a unit with no entry, reproducing the old
4349/// `.unwrap_or(empty)` read semantics without the dance.
4350pub struct UnitInfo {
4351    pub kind: UnitKind,
4352    pub table: UnitTable,
4353    pub uses: Vec<String>,
4354    pub consumes: Vec<String>,
4355    pub flattened: HashMap<String, String>,
4356    pub aliases: HashMap<String, String>,
4357    pub exports: HashMap<String, Visibility>,
4358    pub file_index: FileDeclIndex,
4359    pub files: Vec<usize>,
4360}
4361
4362/// v0.29.4: fold the nine parallel per-unit maps into one `HashMap<String,
4363/// UnitInfo>`. Assembly is driven by the `groups` keyset (the authority), so
4364/// every group yields exactly one record. Facets that are genuinely optional in
4365/// the producer maps (`exports`/`aliases`/`flattened`, and `file_index` for a
4366/// unit with no declarations) default to empty — reproducing the old
4367/// `.unwrap_or(empty)` read semantics as a total field.
4368#[allow(clippy::too_many_arguments)]
4369pub fn assemble_unit_info(
4370    groups: &BTreeMap<String, Vec<usize>>,
4371    kinds: &BTreeMap<String, UnitKind>,
4372    unit_tables: &HashMap<String, UnitTable>,
4373    unit_uses: &HashMap<String, Vec<String>>,
4374    unit_consumes: &HashMap<String, Vec<String>>,
4375    unit_flattened: &HashMap<String, HashMap<String, String>>,
4376    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
4377    exports_visibility: &HashMap<String, HashMap<String, Visibility>>,
4378    unit_file_index: &HashMap<String, FileDeclIndex>,
4379) -> BTreeMap<String, UnitInfo> {
4380    groups
4381        .iter()
4382        .map(|(name, indices)| {
4383            let info = UnitInfo {
4384                kind: *kinds.get(name).unwrap(),
4385                table: unit_tables.get(name).unwrap().clone(),
4386                uses: unit_uses.get(name).cloned().unwrap_or_default(),
4387                consumes: unit_consumes.get(name).cloned().unwrap_or_default(),
4388                flattened: unit_flattened.get(name).cloned().unwrap_or_default(),
4389                aliases: unit_consumes_aliases.get(name).cloned().unwrap_or_default(),
4390                exports: exports_visibility.get(name).cloned().unwrap_or_default(),
4391                file_index: unit_file_index
4392                    .get(name)
4393                    .cloned()
4394                    .unwrap_or_else(|| FileDeclIndex {
4395                        types: HashMap::new(),
4396                        fns: HashMap::new(),
4397                        methods: HashMap::new(),
4398                        agents: HashMap::new(),
4399                        capabilities: HashMap::new(),
4400                    }),
4401                files: indices.clone(),
4402            };
4403            (name.clone(), info)
4404        })
4405        .collect()
4406}
4407
4408/// Phase 8c: collect every method authored anywhere in one unit, keyed by its
4409/// attached type's name — so a type's methods surface in the file that declares
4410/// the type even when the method lives in a sibling file. The collection loop
4411/// has no `continue`s, so it lifts out whole.
4412pub fn collect_unit_methods(
4413    indices: &[usize],
4414    parsed: &[ParsedFile],
4415) -> HashMap<String, Vec<FnDecl>> {
4416    let mut local_methods_for_type: HashMap<String, Vec<FnDecl>> = HashMap::new();
4417    for &j in indices {
4418        for item in parsed[j].items() {
4419            if let CommonsItem::Fn(f) = item
4420                && let FnName::Method { type_name, .. } = &f.name
4421            {
4422                local_methods_for_type
4423                    .entry(type_name.name.clone())
4424                    .or_default()
4425                    .push(f.clone());
4426            }
4427        }
4428    }
4429    local_methods_for_type
4430}
4431
4432/// Phase 8b: merge one context's `consumes` exports into the composed symbol
4433/// space, recording visibility metadata in the returned `consumed_types`. The
4434/// per-export `continue`s (missing decl, name conflict) stay internal to the
4435/// loop, which lifts out whole; name conflicts are pushed into `errors` and the
4436/// caller's `group_error_baseline` guard reacts to them after this returns.
4437#[allow(clippy::too_many_arguments)]
4438pub fn merge_consumed_exports(
4439    name: &str,
4440    parsed: &[ParsedFile],
4441    unit_info: &BTreeMap<String, UnitInfo>,
4442    combined_types: &mut HashMap<String, Arc<TypeDecl>>,
4443    combined_methods: &mut HashMap<String, ResolverMethodTable>,
4444    imported_from: &mut HashMap<String, String>,
4445    imported_from_kind: &mut HashMap<String, UnitKind>,
4446    errors: &mut ErrorSink,
4447) -> HashMap<String, ConsumedType> {
4448    // Names visible from `consumes` (read-only types from consumed contexts).
4449    // For each name we track:
4450    // - the type decl, with the consumed context's identity
4451    // - the visibility (opaque/transparent)
4452    // - the owning context's qualified name (for external-construction errors)
4453    let mut consumed_types: HashMap<String, ConsumedType> = HashMap::new();
4454
4455    // Now process `consumes` for contexts: add exported types into the
4456    // symbol table with visibility metadata so the checker can enforce
4457    // construction / inspection rules.
4458    for t in unit_info.get(name).into_iter().flat_map(|i| &i.consumes) {
4459        let used = &unit_info.get(t).expect("consumed unit present").table;
4460        let used_exports = &unit_info[t].exports;
4461        for (type_name, vis) in used_exports {
4462            let Some(decl) = used.types.get(type_name) else {
4463                continue;
4464            };
4465            // #1927: a local value of the export's name (a function, asset,
4466            // service, agent or provider) takes the emitted module's binding
4467            // as a type of that name would, so it conflicts the same way.
4468            let local_value = crate::symbols::local_value_kind(&unit_info[name].table, type_name)
4469                .map(|(kind, _)| kind)
4470                .filter(|kind| !matches!(*kind, "type" | "event"));
4471            if combined_types.contains_key(type_name) || local_value.is_some() {
4472                // Name conflict between local/uses and consumed export.
4473                let consumes_site = consumes_span_of(parsed, &unit_info[name].files, t);
4474                let consumes_span = consumes_site.map(|(_, s)| s).unwrap_or_default();
4475                let consumes_file = consumes_site.map(|(i, _)| parsed[i].identity_path());
4476                let in_scope = match local_value {
4477                    Some(kind) => format!("this context declares a {kind} of the same name"),
4478                    None => "a type of the same name is already in scope".to_string(),
4479                };
4480                errors.push_for(consumes_file.as_deref(),
4481                    CompileError::new(
4482                        "bynk.consumes.name_conflict",
4483                        consumes_span,
4484                        format!(
4485                            "context `{name}` consumes `{t}` which exports type `{type_name}`, but {in_scope}",
4486                        ),
4487                    )
4488                    .with_note(
4489                        "rename one of the conflicting declarations or restructure the import",
4490                    ),
4491                );
4492                continue;
4493            }
4494            combined_types.insert(type_name.clone(), decl.clone());
4495            imported_from.insert(type_name.clone(), t.clone());
4496            imported_from_kind.insert(type_name.clone(), UnitKind::Context);
4497            consumed_types.insert(
4498                type_name.clone(),
4499                ConsumedType {
4500                    owning_context: t.clone(),
4501                    visibility: *vis,
4502                },
4503            );
4504            // Methods on transparently-exported types: they're emitted in
4505            // the owning context's output, but reading-side methods (like
4506            // user-declared instance methods) are callable from consumers.
4507            // For v0.4, we expose all instance methods on consumed types
4508            // so the checker can resolve method calls; the checker
4509            // separately enforces that constructors (.of/unsafe) aren't
4510            // callable externally.
4511            if let Some(mt) = used.methods.get(type_name) {
4512                let entry = combined_methods.entry(type_name.clone()).or_default();
4513                for (m, decl) in &mt.instance {
4514                    entry
4515                        .instance
4516                        .entry(m.clone())
4517                        .or_insert_with(|| decl.clone());
4518                }
4519                // We deliberately *don't* import static methods from
4520                // consumed contexts. Static methods can construct new
4521                // values, which is forbidden externally.
4522            }
4523        }
4524    }
4525
4526    consumed_types
4527}
4528
4529/// #1807: close a unit's composed type table over the types its *imported*
4530/// declarations reach.
4531///
4532/// `uses` is one level for naming (ADR 0278): `t.web uses t.model` may not
4533/// write `t.core`'s `Repo`. But `t.model`'s `type Run = { repo: Repo }` still
4534/// carries `Repo` into `t.web`, and the checker resolves that field against
4535/// `t.web`'s table. With `Repo` absent the field's type resolved to nothing,
4536/// and every position it typed went unchecked: `Run { repo: 42 }` compiled,
4537/// and so did `r.repo` read as an `Int`.
4538///
4539/// Each type an imported declaration names is resolved in that declaration's
4540/// own scope: its unit's local types, then its unit's `uses`, the same
4541/// precedence [`compose_unit_symbols`] gives a unit's own names. The walk
4542/// covers type bodies, fn signatures and method signatures, and repeats for
4543/// each type it adds. A type it reaches that this unit's table lacks is added
4544/// with its owning commons as provenance and returned as *hidden*: resolvable
4545/// here, never nameable. The naming gate (`crate::resolver::check_hidden_type_names`)
4546/// rejects a hidden name written in this unit's source.
4547///
4548/// Two cases stop short:
4549/// - A reached type that a context owns is not added. It can only arrive
4550///   through a consumed context's export, so whether it may cross is an
4551///   `exports` question.
4552/// - A reached name that this unit already binds to a *different* unit's
4553///   declaration (its own `type Repo`, say) is `bynk.uses.name_conflict`,
4554///   when the reached type's commons is not one this unit `uses` directly.
4555///   The imported field would otherwise be typed by a declaration the unit
4556///   never chose to shadow. Shadowing a directly used type keeps its existing
4557///   meaning. Provenance is compared by owning unit.
4558///
4559/// #1824: that exemption leaves the same hole open for a directly used
4560/// commons. Such a shadow is recorded rather than reported (see
4561/// [`ShadowedReaches`]), and `crate::shadow_uses` rejects it where the unit's
4562/// own code meets it.
4563///
4564/// Returns each hidden name with its owning commons, which emission needs to
4565/// import it, and the shadows the imported declarations reach.
4566#[allow(clippy::too_many_arguments)]
4567pub fn close_reachable_types(
4568    name: &str,
4569    parsed: &[ParsedFile],
4570    unit_info: &BTreeMap<String, UnitInfo>,
4571    combined_types: &mut HashMap<String, Arc<TypeDecl>>,
4572    combined_fns: &HashMap<String, Arc<FnDecl>>,
4573    combined_methods: &mut HashMap<String, ResolverMethodTable>,
4574    imported_from: &mut HashMap<String, String>,
4575    imported_from_kind: &mut HashMap<String, UnitKind>,
4576    errors: &mut ErrorSink,
4577) -> ReachedTypes {
4578    let mut conflicted: HashSet<String> = HashSet::new();
4579    let mut shadowed = ShadowedReaches::default();
4580    let consumed_signatures = consumed_service_seeds(name, unit_info);
4581    let hidden = close_imported(
4582        unit_info,
4583        combined_types,
4584        combined_fns,
4585        combined_methods,
4586        imported_from,
4587        imported_from_kind,
4588        consumed_signatures,
4589        |r, owner, bound| {
4590            // #1932: a name a consumed signature reaches but this unit already
4591            // binds is the unit's own type receiving the value structurally
4592            // (§6.5), not a conflict.
4593            if r.signature {
4594                return;
4595            }
4596            let bound = bound.unwrap_or(name);
4597            // Shadowing a type of a commons this unit `uses` directly is the
4598            // existing, visible choice (`1697_derived_names_collide` declares
4599            // its own `Message` beside `uses bynk.locale.types`), so only a
4600            // shadow of a type the unit cannot name is a conflict.
4601            let directly_used = unit_info[name].uses.iter().any(|u| u == owner);
4602            // #1824: the directly used case is a conflict only where this
4603            // unit's code meets the imported position, which `shadow_uses`
4604            // decides after checking.
4605            if bound != owner && directly_used {
4606                shadowed.0.insert(
4607                    (r.scope.clone(), r.name.clone()),
4608                    ShadowedType {
4609                        name: r.name.clone(),
4610                        owner: owner.to_string(),
4611                        bound: bound.to_string(),
4612                    },
4613                );
4614            }
4615            if bound != owner && !directly_used && conflicted.insert(r.name.clone()) {
4616                let site = uses_span_of(parsed, &unit_info[name].files, &r.via)
4617                    .or_else(|| consumes_span_of(parsed, &unit_info[name].files, &r.via));
4618                let span = site.map(|(_, s)| s).unwrap_or_default();
4619                let file = site.map(|(i, _)| parsed[i].identity_path());
4620                errors.push_for(
4621                    file.as_deref(),
4622                    CompileError::new(
4623                        "bynk.uses.name_conflict",
4624                        span,
4625                        format!(
4626                            "`{via}` brings in declarations that use type `{t}` from `{owner}`, \
4627                             but `{t}` in `{name}` names `{bound}`'s type",
4628                            via = r.via,
4629                            t = r.name,
4630                        ),
4631                    )
4632                    .with_note(
4633                        "rename one of the two types; the imported declarations cannot be \
4634                         checked against a different type of the same name",
4635                    ),
4636                );
4637            }
4638        },
4639    );
4640    ReachedTypes { hidden, shadowed }
4641}
4642
4643/// #1814: close a test suite's composed view over the types its imported
4644/// declarations reach, as [`close_reachable_types`] closes a unit's table.
4645///
4646/// A suite's view is not a unit's: a unit suite's privileged view and a
4647/// `system` case's harness table each merge their own `imported` units (the
4648/// target's `uses` and consumed contexts; the suite's `uses` and the
4649/// participants) one level deep. A declaration of `imported` that won its
4650/// name in the view (the same `Arc`, first in merge order) seeds the walk, in
4651/// its declaring unit's scope. What the walk adds is returned as hidden, for
4652/// the naming gate and for the test scaffold to import from its owning
4653/// commons.
4654///
4655/// A reached name the view already binds to a different declaration is left
4656/// bound as it is and returned as a [`ViewConflict`]; the caller decides
4657/// which it reports (review of #1814). A `system` harness reports every one:
4658/// its view pairs the suite's own `uses` with the participants, which no
4659/// unit's closure ever sees together. A unit suite reports only those its
4660/// target's closure cannot: a binding to a consumed context's unexported
4661/// type, which only the privileged view merges.
4662pub fn close_view_types(
4663    imported: &[String],
4664    unit_tables: &HashMap<String, UnitTable>,
4665    unit_uses: &HashMap<String, Vec<String>>,
4666    types: &mut HashMap<String, Arc<TypeDecl>>,
4667    fns: &HashMap<String, Arc<FnDecl>>,
4668    methods: &mut HashMap<String, ResolverMethodTable>,
4669) -> ViewReach {
4670    let mut imported_from: HashMap<String, String> = HashMap::new();
4671    for u in imported {
4672        let Some(t) = unit_tables.get(u) else {
4673            continue;
4674        };
4675        for (n, d) in &t.types {
4676            if types.get(n).is_some_and(|v| Arc::ptr_eq(v, d)) {
4677                imported_from.entry(n.clone()).or_insert_with(|| u.clone());
4678            }
4679        }
4680        for (n, d) in &t.fns {
4681            if fns.get(n).is_some_and(|v| Arc::ptr_eq(v, d)) {
4682                imported_from.entry(n.clone()).or_insert_with(|| u.clone());
4683            }
4684        }
4685    }
4686    let mut conflicts: Vec<ViewConflict> = Vec::new();
4687    let mut seen: HashSet<String> = HashSet::new();
4688    let hidden = close_imported(
4689        &TableScopes {
4690            tables: unit_tables,
4691            uses: unit_uses,
4692        },
4693        types,
4694        fns,
4695        methods,
4696        &mut imported_from,
4697        &mut HashMap::new(),
4698        Vec::new(),
4699        |r, owner, bound| {
4700            if bound != Some(owner) && seen.insert(r.name.clone()) {
4701                conflicts.push(ViewConflict {
4702                    name: r.name.clone(),
4703                    owner: owner.to_string(),
4704                    bound: bound.map(str::to_string),
4705                    via: r.via.clone(),
4706                });
4707            }
4708        },
4709    );
4710    ViewReach { hidden, conflicts }
4711}
4712
4713/// #1814: what [`close_view_types`] found.
4714#[derive(Debug, Clone, Default)]
4715pub struct ViewReach {
4716    /// Each type the closure added, with its owning commons.
4717    pub hidden: BTreeMap<String, String>,
4718    /// Each reached name the view already bound to another declaration, in
4719    /// the walk's order, once per name.
4720    pub conflicts: Vec<ViewConflict>,
4721}
4722
4723/// #1814 review: a type an imported declaration of a suite view names, which
4724/// the view binds to a different declaration of the same name.
4725#[derive(Debug, Clone, PartialEq, Eq)]
4726pub struct ViewConflict {
4727    /// The name both declarations share.
4728    pub name: String,
4729    /// The commons whose declaration the imported one means.
4730    pub owner: String,
4731    /// The unit whose declaration the view binds the name to; `None` for the
4732    /// view's own (a unit suite's target's local type).
4733    pub bound: Option<String>,
4734    /// The imported unit the reaching declaration came from.
4735    pub via: String,
4736}
4737
4738/// #1814: what the reachable-type walk reads of a unit: its declared table,
4739/// its `uses`, and its kind (`None` for a table built without one, which is
4740/// then a scope but never a commons, so nothing it declares is added).
4741/// A unit's own closure reads the assembled [`UnitInfo`]s; a suite view's
4742/// reads the per-unit maps the suite phases carry.
4743trait ScopeTables {
4744    fn scope(&self, unit: &str) -> Option<(&UnitTable, &[String], Option<UnitKind>)>;
4745}
4746
4747impl ScopeTables for BTreeMap<String, UnitInfo> {
4748    fn scope(&self, unit: &str) -> Option<(&UnitTable, &[String], Option<UnitKind>)> {
4749        self.get(unit)
4750            .map(|i| (&i.table, i.uses.as_slice(), Some(i.kind)))
4751    }
4752}
4753
4754/// #1814: [`ScopeTables`] over the suite phases' per-unit maps.
4755struct TableScopes<'a> {
4756    tables: &'a HashMap<String, UnitTable>,
4757    uses: &'a HashMap<String, Vec<String>>,
4758}
4759
4760impl ScopeTables for TableScopes<'_> {
4761    fn scope(&self, unit: &str) -> Option<(&UnitTable, &[String], Option<UnitKind>)> {
4762        let table = self.tables.get(unit)?;
4763        let uses = self.uses.get(unit).map(Vec::as_slice).unwrap_or_default();
4764        Some((table, uses, table.kind))
4765    }
4766}
4767
4768/// #1807: a type name an imported declaration references: resolved in
4769/// `scope` (the declaring unit), and reached through `via` (the `uses` or
4770/// `consumes` target the declaration was imported from, which a conflict
4771/// points at).
4772struct Reached {
4773    name: String,
4774    scope: String,
4775    via: String,
4776    /// #1932: reached from a consumed service's signature (or from a type such
4777    /// a reach added). The value crosses structurally (§6.5), so a name the
4778    /// consumer already binds is its own type receiving it, not a conflict.
4779    signature: bool,
4780}
4781
4782fn push_refs(names: Vec<String>, scope: &str, via: &str, queue: &mut Vec<Reached>) {
4783    push_refs_with(names, scope, via, false, queue);
4784}
4785
4786fn push_refs_with(
4787    names: Vec<String>,
4788    scope: &str,
4789    via: &str,
4790    signature: bool,
4791    queue: &mut Vec<Reached>,
4792) {
4793    queue.extend(names.into_iter().map(|n| Reached {
4794        name: n,
4795        scope: scope.to_string(),
4796        via: via.to_string(),
4797        signature,
4798    }));
4799}
4800
4801/// #1932: the type names the `on call` handlers of every context `name`
4802/// consumes reference, resolved in that context's scope and reached through
4803/// its `consumes`, in a stable order. A consumed service's signature crosses
4804/// the boundary (§6.5), so a commons type in it is reached like one in an
4805/// imported declaration: the consumer resolves it, and emission imports its
4806/// codecs from the declaring commons, whether or not the consumer `uses` it.
4807fn consumed_service_seeds(name: &str, unit_info: &BTreeMap<String, UnitInfo>) -> Vec<Reached> {
4808    let mut out = Vec::new();
4809    let Some(info) = unit_info.get(name) else {
4810        return out;
4811    };
4812    for t in &info.consumes {
4813        let Some(consumed) = unit_info.get(t) else {
4814            continue;
4815        };
4816        let mut services: Vec<&ServiceDecl> = consumed.table.services.values().collect();
4817        services.sort_by(|a, b| a.name.name.cmp(&b.name.name));
4818        for s in services {
4819            // The one `on call` handler a cross-context call reaches
4820            // (`resolver::cross_context_service_for`).
4821            if let Some(h) = s.handlers.iter().find(|h| h.kind == HandlerKind::Call) {
4822                let mut names = Vec::new();
4823                for p in &h.params {
4824                    type_ref_names(&p.type_ref, &HashSet::new(), &mut names);
4825                }
4826                type_ref_names(&h.return_type, &HashSet::new(), &mut names);
4827                push_refs_with(names, t, t, true, &mut out);
4828            }
4829        }
4830    }
4831    out
4832}
4833
4834/// #1807, #1814: the walk [`close_reachable_types`] and [`close_view_types`]
4835/// share. Seeds from every entry `imported_from` attributes to another unit,
4836/// adds each reached commons type the table lacks (with its methods), and
4837/// calls `on_bound(reached, owner, bound)` for a reached commons type whose
4838/// name the table already binds, `bound` being that binding's provenance
4839/// (`None` for the table's own declaration).
4840#[allow(clippy::too_many_arguments)]
4841fn close_imported<S: ScopeTables + ?Sized>(
4842    scopes: &S,
4843    combined_types: &mut HashMap<String, Arc<TypeDecl>>,
4844    combined_fns: &HashMap<String, Arc<FnDecl>>,
4845    combined_methods: &mut HashMap<String, ResolverMethodTable>,
4846    imported_from: &mut HashMap<String, String>,
4847    imported_from_kind: &mut HashMap<String, UnitKind>,
4848    // #1932: further seeds, each resolved in its own scope (a consumed
4849    // service's signature).
4850    extra_seeds: Vec<Reached>,
4851    mut on_bound: impl FnMut(&Reached, &str, Option<&str>),
4852) -> BTreeMap<String, String> {
4853    let mut queue: Vec<Reached> = Vec::new();
4854    let mut type_names: Vec<&String> = combined_types.keys().collect();
4855    type_names.sort();
4856    for n in type_names {
4857        if let Some(unit) = imported_from.get(n) {
4858            push_refs(
4859                type_decl_ref_names(&combined_types[n]),
4860                unit,
4861                unit,
4862                &mut queue,
4863            );
4864        }
4865    }
4866    let mut fn_names: Vec<&String> = combined_fns.keys().collect();
4867    fn_names.sort();
4868    for n in fn_names {
4869        if let Some(unit) = imported_from.get(n) {
4870            push_refs(
4871                fn_sig_ref_names(&combined_fns[n], &[]),
4872                unit,
4873                unit,
4874                &mut queue,
4875            );
4876        }
4877    }
4878    let mut method_types: Vec<&String> = combined_methods.keys().collect();
4879    method_types.sort();
4880    for t in method_types {
4881        if let Some(unit) = imported_from.get(t) {
4882            let mt = &combined_methods[t];
4883            let attached = combined_types.get(t).map(|d| d.type_params.as_slice());
4884            let mut decls: Vec<&Arc<FnDecl>> =
4885                mt.instance.values().chain(mt.statics.values()).collect();
4886            decls.sort_by_key(|d| d.span.start);
4887            for d in decls {
4888                push_refs(
4889                    fn_sig_ref_names(d, attached.unwrap_or_default()),
4890                    unit,
4891                    unit,
4892                    &mut queue,
4893                );
4894            }
4895        }
4896    }
4897    queue.extend(extra_seeds);
4898    // The walk pops from the back; reverse so names are visited in the
4899    // sorted seeding order and diagnostics come out deterministically.
4900    queue.reverse();
4901
4902    let mut hidden: BTreeMap<String, String> = BTreeMap::new();
4903    let mut visited: HashSet<(String, String)> = HashSet::new();
4904    while let Some(r) = queue.pop() {
4905        if !visited.insert((r.scope.clone(), r.name.clone())) {
4906            continue;
4907        }
4908        let Some(owner) = type_owner_in_scope(&r.scope, &r.name, scopes) else {
4909            continue;
4910        };
4911        // Review of #1813: before the conflict check, so a context-owned
4912        // reach is skipped whether or not this unit binds the name (a
4913        // consumer's own `Money` beside a consumed export's field of the
4914        // exporting context's `Money` is not a conflict).
4915        let Some((owner_table, _, owner_kind)) = scopes.scope(owner) else {
4916            continue;
4917        };
4918        if owner_kind != Some(UnitKind::Commons) {
4919            continue;
4920        }
4921        if combined_types.contains_key(&r.name) {
4922            on_bound(&r, owner, imported_from.get(&r.name).map(String::as_str));
4923            continue;
4924        }
4925        let decl = owner_table.types[&r.name].clone();
4926        push_refs_with(
4927            type_decl_ref_names(&decl),
4928            owner,
4929            &r.via,
4930            r.signature,
4931            &mut queue,
4932        );
4933        combined_types.insert(r.name.clone(), decl.clone());
4934        imported_from.insert(r.name.clone(), owner.to_string());
4935        imported_from_kind.insert(r.name.clone(), UnitKind::Commons);
4936        hidden.insert(r.name.clone(), owner.to_string());
4937        // The reached type's methods come along, as a direct `uses` brings
4938        // them: `r.repo.isValid()` resolves in the declaring scope too.
4939        if let Some(mt) = owner_table.methods.get(&r.name) {
4940            let entry = combined_methods.entry(r.name.clone()).or_default();
4941            let mut decls: Vec<(&String, &Arc<FnDecl>, bool)> = mt
4942                .instance
4943                .iter()
4944                .map(|(m, d)| (m, d, true))
4945                .chain(mt.statics.iter().map(|(m, d)| (m, d, false)))
4946                .collect();
4947            decls.sort_by_key(|(_, d, _)| d.span.start);
4948            for (m, d, is_instance) in decls {
4949                push_refs_with(
4950                    fn_sig_ref_names(d, &decl.type_params),
4951                    owner,
4952                    &r.via,
4953                    r.signature,
4954                    &mut queue,
4955                );
4956                let table = if is_instance {
4957                    &mut entry.instance
4958                } else {
4959                    &mut entry.statics
4960                };
4961                table.entry(m.clone()).or_insert_with(|| d.clone());
4962            }
4963        }
4964    }
4965    hidden
4966}
4967
4968/// What [`close_reachable_types`] finds beyond the unit's composed table.
4969#[derive(Debug, Clone, Default)]
4970pub struct ReachedTypes {
4971    /// Each hidden type name with its owning commons, which emission needs to
4972    /// import it.
4973    pub hidden: BTreeMap<String, String>,
4974    /// #1824: the reached names this unit's own declaration shadows.
4975    pub shadowed: ShadowedReaches,
4976}
4977
4978/// #1824: a type an imported declaration names, which this unit resolves to a
4979/// different declaration of the same name.
4980#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
4981pub struct ShadowedType {
4982    /// The name both declarations share.
4983    pub name: String,
4984    /// The commons whose declaration the imported one means.
4985    pub owner: String,
4986    /// The unit whose declaration the name resolves to here (the unit itself,
4987    /// for its own `type`).
4988    pub bound: String,
4989}
4990
4991/// #1824: the reached type names a unit's own declaration shadows, keyed by
4992/// the declaring unit whose scope resolves the name and the name.
4993///
4994/// Type names are bare strings to the checker, so an imported declaration's
4995/// `Repo` is checked as whatever `Repo` the importing unit binds. When that is
4996/// the unit's own `type Repo`, beside a `uses` of the commons the imported
4997/// declaration means, the checker accepts what the emitted TypeScript rejects.
4998/// `close_reachable_types` records each such reach here; `crate::shadow_uses`
4999/// reports the positions the unit's code fills or reads.
5000#[derive(Debug, Clone, Default)]
5001pub struct ShadowedReaches(HashMap<(String, String), ShadowedType>);
5002
5003impl ShadowedReaches {
5004    /// No imported declaration reaches a shadowed type.
5005    pub fn is_empty(&self) -> bool {
5006        self.0.is_empty()
5007    }
5008
5009    /// The shadowed types `r` names, resolved in `scope` (the unit that
5010    /// declares the position), its type variables `vars` excluded. Sorted, so
5011    /// two positions compare equal when they name the same shadowed types.
5012    pub fn in_type_ref(&self, scope: &str, r: &TypeRef, vars: &[TypeParam]) -> Vec<&ShadowedType> {
5013        let vars: HashSet<&str> = vars.iter().map(|p| p.name.name.as_str()).collect();
5014        let mut names = Vec::new();
5015        type_ref_names(r, &vars, &mut names);
5016        let mut out: Vec<&ShadowedType> = names
5017            .iter()
5018            .filter_map(|n| self.0.get(&(scope.to_string(), n.clone())))
5019            .collect();
5020        out.sort();
5021        out.dedup();
5022        out
5023    }
5024}
5025
5026/// #1807: the unit that owns type `name` as seen from `scope`: `scope` itself
5027/// if it declares `name`, else the first of `scope`'s `uses` that does. This
5028/// is [`compose_unit_symbols`]'s precedence, applied to another unit.
5029fn type_owner_in_scope<'a, S: ScopeTables + ?Sized>(
5030    scope: &'a str,
5031    name: &str,
5032    scopes: &'a S,
5033) -> Option<&'a str> {
5034    let (table, uses, _) = scopes.scope(scope)?;
5035    if table.types.contains_key(name) {
5036        return Some(scope);
5037    }
5038    uses.iter()
5039        .find(|u| {
5040            scopes
5041                .scope(u)
5042                .is_some_and(|(t, _, _)| t.types.contains_key(name))
5043        })
5044        .map(String::as_str)
5045}
5046
5047/// #1807: the type names a type declaration's body references, its own type
5048/// parameters excluded.
5049pub(crate) fn type_decl_ref_names(d: &TypeDecl) -> Vec<String> {
5050    let vars: HashSet<&str> = d.type_params.iter().map(|p| p.name.name.as_str()).collect();
5051    let mut out = Vec::new();
5052    match &d.body {
5053        TypeBody::Record(r) => {
5054            for f in &r.fields {
5055                type_ref_names(&f.type_ref, &vars, &mut out);
5056            }
5057        }
5058        TypeBody::Sum(s) => {
5059            for v in &s.variants {
5060                for p in &v.payload {
5061                    type_ref_names(&p.type_ref, &vars, &mut out);
5062                }
5063            }
5064        }
5065        TypeBody::Refined { .. } | TypeBody::Opaque { .. } => {}
5066    }
5067    out
5068}
5069
5070/// #1807: the type names a fn's signature references, its own type
5071/// parameters excluded, and for a method its type's (`attached`):
5072/// `fn Box.get(self) -> A` on `type Box[A]` declares no `[A]` of its own.
5073fn fn_sig_ref_names(f: &FnDecl, attached: &[TypeParam]) -> Vec<String> {
5074    let vars: HashSet<&str> = f
5075        .type_params
5076        .iter()
5077        .chain(attached)
5078        .map(|p| p.name.name.as_str())
5079        .collect();
5080    let mut out = Vec::new();
5081    for p in &f.params {
5082        type_ref_names(&p.type_ref, &vars, &mut out);
5083    }
5084    type_ref_names(&f.return_type, &vars, &mut out);
5085    out
5086}
5087
5088/// #1807: every user type name in `r`, outside `vars`. Exhaustive over
5089/// `TypeRef`, so a new compound constructor is a build failure here rather
5090/// than a silently skipped name.
5091pub(crate) fn type_ref_names(r: &TypeRef, vars: &HashSet<&str>, out: &mut Vec<String>) {
5092    match r {
5093        TypeRef::Named(id) => {
5094            if !vars.contains(id.name.as_str()) {
5095                out.push(id.name.clone());
5096            }
5097        }
5098        TypeRef::App { name, args, .. } => {
5099            if !vars.contains(name.name.as_str()) {
5100                out.push(name.name.clone());
5101            }
5102            for a in args {
5103                type_ref_names(a, vars, out);
5104            }
5105        }
5106        TypeRef::Result(a, b, _) | TypeRef::Map(a, b, _) => {
5107            type_ref_names(a, vars, out);
5108            type_ref_names(b, vars, out);
5109        }
5110        TypeRef::Option(t, _)
5111        | TypeRef::Effect(t, _)
5112        | TypeRef::HttpResult(t, _)
5113        | TypeRef::List(t, _)
5114        | TypeRef::Query(t, _)
5115        | TypeRef::Stream(t, _)
5116        | TypeRef::Connection(t, _)
5117        | TypeRef::History(t, _) => type_ref_names(t, vars, out),
5118        TypeRef::Fn(params, ret, _) => {
5119            for p in params {
5120                type_ref_names(p, vars, out);
5121            }
5122            type_ref_names(ret, vars, out);
5123        }
5124        TypeRef::Base(..)
5125        | TypeRef::QueueResult(_)
5126        | TypeRef::Asset(_)
5127        | TypeRef::RequestHeaders(_)
5128        | TypeRef::TokenClaims(_)
5129        | TypeRef::BoundaryRejection(_)
5130        | TypeRef::ValidationError(_)
5131        | TypeRef::JsonError(_)
5132        | TypeRef::Unit(_) => {}
5133    }
5134}
5135
5136/// Phase 8a: compose one unit's symbol space — its local table plus a
5137/// one-level `uses` mixin (commons identity preserved). Returns the combined
5138/// type/fn/method tables and the `imported_from` provenance maps; the mixin
5139/// loop has no `continue`s, so it lifts out whole.
5140#[allow(clippy::type_complexity)]
5141pub fn compose_unit_symbols(
5142    name: &str,
5143    local_table: &UnitTable,
5144    unit_info: &BTreeMap<String, UnitInfo>,
5145) -> (
5146    HashMap<String, Arc<TypeDecl>>,
5147    HashMap<String, Arc<FnDecl>>,
5148    HashMap<String, ResolverMethodTable>,
5149    HashMap<String, String>,
5150    HashMap<String, UnitKind>,
5151) {
5152    // Compose: local + transitive (one level) uses. For commons, mixin
5153    // preserves type identity; for contexts, mixin produces per-context
5154    // nominal types. The resolver doesn't distinguish (the rebranding is
5155    // observable in emission); the symbol table union is the same.
5156    let mut combined_types = local_table.types.clone();
5157    let mut combined_fns = local_table.fns.clone();
5158    let mut combined_methods = local_table.methods.clone();
5159    let mut imported_from: HashMap<String, String> = HashMap::new();
5160    let mut imported_from_kind: HashMap<String, UnitKind> = HashMap::new();
5161
5162    for t in unit_info.get(name).into_iter().flat_map(|i| &i.uses) {
5163        let used = &unit_info.get(t).expect("used unit present").table;
5164        for (type_name, decl) in &used.types {
5165            if !combined_types.contains_key(type_name) {
5166                combined_types.insert(type_name.clone(), decl.clone());
5167                imported_from.insert(type_name.clone(), t.clone());
5168                imported_from_kind.insert(type_name.clone(), UnitKind::Commons);
5169            }
5170        }
5171        for (fn_name, decl) in &used.fns {
5172            // #1925: a local declaration of any kind shadows the import, as
5173            // it does in the emitted module ([`shadowed_imports`]).
5174            if !combined_fns.contains_key(fn_name)
5175                && crate::symbols::local_value_kind(local_table, fn_name).is_none()
5176            {
5177                combined_fns.insert(fn_name.clone(), decl.clone());
5178                imported_from.insert(fn_name.clone(), t.clone());
5179                imported_from_kind.insert(fn_name.clone(), UnitKind::Commons);
5180            }
5181        }
5182        // #1883, slice 1b: a used commons' assets are visible here
5183        // (`assets::compose_unit_assets`); record where each came from, so
5184        // the emitter imports its constant from the declaring module. A
5185        // local declaration of the same name, of any kind, wins (#1925).
5186        for asset_name in used.assets.keys() {
5187            if crate::symbols::local_value_kind(local_table, asset_name).is_none()
5188                && !combined_fns.contains_key(asset_name)
5189                && !imported_from.contains_key(asset_name)
5190            {
5191                imported_from.insert(asset_name.clone(), t.clone());
5192                imported_from_kind.insert(asset_name.clone(), UnitKind::Commons);
5193            }
5194        }
5195        // #2387, slice 2: likewise a used commons' embeds.
5196        for embed_name in used.embeds.keys() {
5197            if crate::symbols::local_value_kind(local_table, embed_name).is_none()
5198                && !combined_fns.contains_key(embed_name)
5199                && !imported_from.contains_key(embed_name)
5200            {
5201                imported_from.insert(embed_name.clone(), t.clone());
5202                imported_from_kind.insert(embed_name.clone(), UnitKind::Commons);
5203            }
5204        }
5205        for (type_name, mt) in &used.methods {
5206            let entry = combined_methods.entry(type_name.clone()).or_default();
5207            for (m, decl) in &mt.instance {
5208                entry
5209                    .instance
5210                    .entry(m.clone())
5211                    .or_insert_with(|| decl.clone());
5212            }
5213            for (m, decl) in &mt.statics {
5214                entry
5215                    .statics
5216                    .entry(m.clone())
5217                    .or_insert_with(|| decl.clone());
5218            }
5219        }
5220    }
5221
5222    (
5223        combined_types,
5224        combined_fns,
5225        combined_methods,
5226        imported_from,
5227        imported_from_kind,
5228    )
5229}
5230
5231/// #1925: the `uses`-imported functions and assets that unit `name`'s own
5232/// declarations of another kind shadow, by name. A local declaration wins, as
5233/// in the emitted module, so both compositions leave these out
5234/// ([`compose_unit_symbols`], [`crate::assets::compose_unit_assets`]); the
5235/// resolver and checker report a reference the local declaration cannot answer
5236/// as `bynk.uses.name_conflict` ([`ResolvedCommons::shadowed_import_error`]).
5237/// A same-kind shadow (a local function over an imported one) is ordinary
5238/// shadowing and is not listed. The first `uses` target to declare a name
5239/// decides, as in the compositions.
5240///
5241/// #1927: an imported type a local value hides is listed too, but stays in
5242/// the composed table: an imported declaration that uses it must still check,
5243/// and a value of it may flow through the unit's code. Writing its name is the
5244/// conflict instead ([`crate::resolver::check_shadowed_type_names`]). A local
5245/// type or event over an imported type is #1824's type shadowing, not listed.
5246///
5247/// [`ResolvedCommons::shadowed_import_error`]: crate::resolver::ResolvedCommons::shadowed_import_error
5248pub fn shadowed_imports(
5249    name: &str,
5250    unit_info: &BTreeMap<String, UnitInfo>,
5251) -> HashMap<String, crate::resolver::ShadowedImport> {
5252    let Some(info) = unit_info.get(name) else {
5253        return HashMap::new();
5254    };
5255    shadowed_imports_in(
5256        &info.table,
5257        info.uses
5258            .iter()
5259            .filter_map(|t| unit_info.get(t).map(|u| (t, &u.table))),
5260    )
5261}
5262
5263/// [`shadowed_imports`] over a unit's own table and its `uses` targets'
5264/// tables, in `uses` order — for a caller holding tables rather than
5265/// [`UnitInfo`]s (a suite's view of its target, `crate::test_suites`).
5266pub fn shadowed_imports_in<'a>(
5267    local: &UnitTable,
5268    uses: impl Iterator<Item = (&'a String, &'a UnitTable)>,
5269) -> HashMap<String, crate::resolver::ShadowedImport> {
5270    let mut out = HashMap::new();
5271    for (t, used) in uses {
5272        // #1927: an imported type too. A local type or event over it is
5273        // #1824's type shadowing, not this.
5274        let imports = used
5275            .fns
5276            .keys()
5277            .map(|n| (n, "function"))
5278            .chain(used.assets.keys().map(|n| (n, "asset")))
5279            .chain(used.types.keys().map(|n| (n, "type")));
5280        for (n, imported) in imports {
5281            if let Some((kind, local_span)) = crate::symbols::local_value_kind(local, n)
5282                && kind != imported
5283                && !(imported == "type" && kind == "event")
5284            {
5285                let hidden = crate::resolver::ShadowedImport {
5286                    unit: t.clone(),
5287                    imported,
5288                    local: kind,
5289                    local_span,
5290                };
5291                // The first `uses` target to declare a name decides, except
5292                // that a type replaces a function or asset (review of #1933).
5293                // The local value hides both imports, but only the type stays
5294                // composed, so only its entry gates the name: kept second, it
5295                // would let `uses` order decide whether the unit may write it.
5296                match out.entry(n.clone()) {
5297                    std::collections::hash_map::Entry::Vacant(e) => {
5298                        e.insert(hidden);
5299                    }
5300                    std::collections::hash_map::Entry::Occupied(mut e) => {
5301                        if imported == "type" && e.get().imported != "type" {
5302                            e.insert(hidden);
5303                        }
5304                    }
5305                }
5306            }
5307        }
5308    }
5309    out
5310}
5311
5312/// Phase 5c: detect `consumes` cycles. #696: record each `consumes`-clause
5313/// site (file + span) keyed by `(consumer, target)` so a detected cycle
5314/// anchors on the exact clause that forms the closing edge — a real span in
5315/// a real file — and renders with source context. Synthetic units are left
5316/// out so their (snapshot-less) files never claim a diagnostic.
5317pub fn phase_detect_consumes_cycles(
5318    groups: &BTreeMap<String, Vec<usize>>,
5319    parsed: &[ParsedFile],
5320    unit_consumes: &HashMap<String, Vec<String>>,
5321    errors: &mut ErrorSink,
5322) {
5323    let mut consumes_sites: HashMap<(String, String), (PathBuf, Span)> = HashMap::new();
5324    for (name, indices) in groups {
5325        for &i in indices {
5326            if parsed[i].is_synthetic() {
5327                continue;
5328            }
5329            for c in parsed[i].consumes() {
5330                consumes_sites
5331                    .entry((name.clone(), c.target.joined()))
5332                    .or_insert_with(|| (parsed[i].identity_path(), c.span));
5333            }
5334        }
5335    }
5336    let mut cycle_errors: Vec<(Option<PathBuf>, CompileError)> = Vec::new();
5337    detect_consumes_cycles(unit_consumes, &consumes_sites, &mut cycle_errors);
5338    for (path, err) in cycle_errors {
5339        errors.push_for(path.as_deref(), err);
5340    }
5341}
5342
5343#[cfg(test)]
5344mod platform_lock_tests {
5345    use super::{LockViolation, Platform, lock_violation};
5346    use std::collections::BTreeMap;
5347
5348    fn native(entries: &[(Platform, &str)]) -> BTreeMap<Platform, String> {
5349        entries
5350            .iter()
5351            .map(|(p, u)| (*p, (*u).to_string()))
5352            .collect()
5353    }
5354
5355    #[test]
5356    fn empty_closure_imposes_no_lock() {
5357        assert_eq!(lock_violation(&native(&[]), Platform::Node), None);
5358    }
5359
5360    #[test]
5361    fn matching_platform_is_fine() {
5362        let n = native(&[(Platform::Cloudflare, "bynk.cloudflare")]);
5363        assert_eq!(lock_violation(&n, Platform::Cloudflare), None);
5364    }
5365
5366    #[test]
5367    fn mismatched_platform_is_required() {
5368        let n = native(&[(Platform::Cloudflare, "bynk.cloudflare")]);
5369        assert_eq!(
5370            lock_violation(&n, Platform::Node),
5371            Some(LockViolation::Required {
5372                needed: Platform::Cloudflare,
5373                unit: "bynk.cloudflare".to_string(),
5374            })
5375        );
5376    }
5377
5378    // The conflict arm is not yet reachable end-to-end (only one platform
5379    // ships native capabilities until `bynk.aws`); the rule is exercised here
5380    // with a synthetic two-platform set so it does not ship untested
5381    // (proposal v0.19, review call).
5382    #[test]
5383    fn two_platforms_conflict_regardless_of_selection() {
5384        let n = native(&[
5385            (Platform::Cloudflare, "bynk.cloudflare"),
5386            (Platform::Node, "bynk.synthetic"),
5387        ]);
5388        let v = lock_violation(&n, Platform::Cloudflare);
5389        assert_eq!(
5390            v,
5391            Some(LockViolation::Conflict {
5392                a: (Platform::Cloudflare, "bynk.cloudflare".to_string()),
5393                b: (Platform::Node, "bynk.synthetic".to_string()),
5394            })
5395        );
5396    }
5397}
5398
5399#[cfg(test)]
5400mod native_platform_closure_tests {
5401    use super::{HashMap, Platform, UnitTable, native_platforms_of_context};
5402    use bynk_syntax::ast::{CapRef, Ident, ProviderDecl, QualifiedName};
5403    use bynk_syntax::span::Span;
5404    use std::collections::HashMap as StdHashMap;
5405
5406    fn ident(name: &str) -> Ident {
5407        Ident {
5408            name: name.to_string(),
5409            span: Span::default(),
5410        }
5411    }
5412
5413    fn qualified(parts: &[&str]) -> QualifiedName {
5414        QualifiedName {
5415            parts: parts.iter().map(|p| ident(p)).collect(),
5416            span: Span::default(),
5417        }
5418    }
5419
5420    fn given_cap(prefix: Option<&[&str]>, name: &str) -> CapRef {
5421        CapRef {
5422            context: prefix.map(qualified),
5423            name: ident(name),
5424            span: Span::default(),
5425        }
5426    }
5427
5428    fn provider(capability: &str, given: Vec<CapRef>) -> ProviderDecl {
5429        ProviderDecl {
5430            capability: ident(capability),
5431            provider_name: ident(&format!("{capability}Impl")),
5432            given,
5433            ops: Vec::new(),
5434            external: false,
5435            documentation: None,
5436            span: Span::default(),
5437            trivia: Default::default(),
5438        }
5439    }
5440
5441    fn empty_table() -> UnitTable {
5442        UnitTable {
5443            kind: None,
5444            removed_agents: Vec::new(),
5445            types: StdHashMap::new(),
5446            fns: StdHashMap::new(),
5447            methods: StdHashMap::new(),
5448            capabilities: StdHashMap::new(),
5449            providers: StdHashMap::new(),
5450            services: StdHashMap::new(),
5451            agents: StdHashMap::new(),
5452            actors: StdHashMap::new(),
5453            exported_capabilities: Default::default(),
5454            events: StdHashMap::new(),
5455            flattened_caps: StdHashMap::new(),
5456            assets: StdHashMap::new(),
5457            embeds: StdHashMap::new(),
5458            derived: StdHashMap::new(),
5459        }
5460    }
5461
5462    /// P5.3 review finding (#1133): nothing in the tree exercised
5463    /// `collect_given_closure`'s recursive arm — every existing fixture that
5464    /// reaches `bynk.cloudflare` does so through a handler's bare `given Kv`
5465    /// (`handler_cross_caps`, depth 0: `provider.given.is_empty()` short-
5466    /// circuits immediately), never through a local provider's own `given`
5467    /// chain. This pins the contract `collect_given_closure`'s own doc
5468    /// states: a context whose *only* path to a platform-native unit is a
5469    /// provider's `given` — `provides Cache = LocalCache given
5470    /// bynk.cloudflare.Kv { … }`, with no handler ever naming `Kv` directly —
5471    /// must still be recognised as native. `bynkc/tests/fixtures/negative/
5472    /// 1030_kv_provider_given_wrong_platform` pins the same contract
5473    /// end-to-end through `run_checks`.
5474    #[test]
5475    fn a_providers_given_chain_into_a_platform_native_unit_is_recognised() {
5476        let mut table = empty_table();
5477        table.providers.insert(
5478            "Cache".to_string(),
5479            provider(
5480                "Cache",
5481                vec![given_cap(Some(&["bynk", "cloudflare"]), "Kv")],
5482            ),
5483        );
5484        let mut unit_tables = HashMap::new();
5485        unit_tables.insert("app.web".to_string(), table);
5486        let mut unit_consumes = HashMap::new();
5487        unit_consumes.insert("app.web".to_string(), vec!["bynk.cloudflare".to_string()]);
5488
5489        let native = native_platforms_of_context(
5490            "app.web",
5491            unit_tables.get("app.web").unwrap(),
5492            &unit_tables,
5493            &unit_consumes,
5494            &HashMap::new(),
5495            &HashMap::new(),
5496        );
5497        assert_eq!(
5498            native.get(&Platform::Cloudflare).map(String::as_str),
5499            Some("bynk.cloudflare"),
5500            "a provider's own `given` closure into a platform-native unit must be \
5501             walked recursively, not just a handler's direct `given` — got {native:?}"
5502        );
5503    }
5504
5505    /// A provider whose `given` closure never leaves ordinary (non-native)
5506    /// units contributes nothing — the recursive walk must not manufacture a
5507    /// platform out of thin air.
5508    #[test]
5509    fn a_providers_given_chain_into_an_ordinary_unit_is_not_native() {
5510        let mut table = empty_table();
5511        table.providers.insert(
5512            "Cache".to_string(),
5513            provider("Cache", vec![given_cap(None, "Clock")]),
5514        );
5515        let mut unit_tables = HashMap::new();
5516        unit_tables.insert("app.web".to_string(), table);
5517
5518        let native = native_platforms_of_context(
5519            "app.web",
5520            unit_tables.get("app.web").unwrap(),
5521            &unit_tables,
5522            &HashMap::new(),
5523            &HashMap::new(),
5524            &HashMap::new(),
5525        );
5526        assert!(
5527            native.is_empty(),
5528            "a `given` closure that never reaches a platform-native unit must not \
5529             report one — got {native:?}"
5530        );
5531    }
5532}
5533
5534#[cfg(test)]
5535mod view_closure_tests {
5536    use super::{HashMap, UnitTable, build_unit_table, close_view_types};
5537    use bynk_project::{ParsedFile, UnitKind};
5538    use bynk_syntax::{lexer, parser};
5539    use std::path::PathBuf;
5540
5541    fn table(name: &str, kind: UnitKind, src: &str) -> UnitTable {
5542        let tokens = lexer::tokenize(src).expect("lex");
5543        let unit = parser::parse_unit(&tokens, src).expect("parse");
5544        let path = PathBuf::from(format!("{}.bynk", name.replace('.', "/")));
5545        let pf = ParsedFile::new(path.clone(), path, None, src.to_string(), unit, kind, false);
5546        let mut errors = Vec::new();
5547        build_unit_table(name, kind, &[0], &[pf], &mut errors)
5548    }
5549
5550    /// Review of #1814: a table built without a kind (`UnitTable::default()`,
5551    /// the shape several harnesses build) is still a scope. Its types and its
5552    /// `uses` are searched; only its own declarations, never a commons', are
5553    /// not added. It used to make the scope invisible, so a type reached
5554    /// through it resolved to nothing, silently.
5555    #[test]
5556    fn a_kindless_table_is_still_searched_as_a_scope() {
5557        let core = table(
5558            "t.core",
5559            UnitKind::Commons,
5560            "commons t.core\n\ntype Repo = String where NonEmpty\n",
5561        );
5562        let mut model = table(
5563            "t.model",
5564            UnitKind::Commons,
5565            "commons t.model\n\nuses t.core\n\ntype Run = { repo: Repo }\n",
5566        );
5567        model.kind = None;
5568        let mut types = model.types.clone();
5569        let tables: HashMap<String, UnitTable> =
5570            [("t.core".to_string(), core), ("t.model".to_string(), model)].into();
5571        let uses: HashMap<String, Vec<String>> =
5572            [("t.model".to_string(), vec!["t.core".to_string()])].into();
5573        let reach = close_view_types(
5574            &["t.model".to_string()],
5575            &tables,
5576            &uses,
5577            &mut types,
5578            &HashMap::new(),
5579            &mut HashMap::new(),
5580        );
5581        assert_eq!(
5582            reach.hidden.get("Repo").map(String::as_str),
5583            Some("t.core"),
5584            "{:?}",
5585            reach.hidden
5586        );
5587        assert!(types.contains_key("Repo"));
5588    }
5589}
5590
5591#[cfg(test)]
5592mod label_attribution_tests {
5593    use super::*;
5594
5595    /// Each label is attributed by the `FileId` its span carries: the error's
5596    /// own file when it shares the primary span's id, has none, or names the
5597    /// error's own path; another registered file by its path; and an
5598    /// unregistered file (a toolchain-injected unit) as unavailable.
5599    #[test]
5600    fn labels_are_attributed_to_the_file_their_span_is_in() {
5601        let (own, sibling, injected) = (FileId(10), FileId(11), FileId(12));
5602        let mut sink = ErrorSink::new();
5603        sink.register_file(own, PathBuf::from("src/app/z_more.bynk"));
5604        sink.register_file(sibling, PathBuf::from("src/app/a_web.bynk"));
5605        let error = CompileError::new(
5606            "bynk.resolve.duplicate_service",
5607            Span::new_in(own, 24, 27),
5608            "service `api` is already declared",
5609        )
5610        .with_label(Span::new_in(own, 0, 1), "same id")
5611        .with_label(Span::new(0, 1), "no id")
5612        .with_label(Span::new_in(sibling, 21, 24), "sibling")
5613        .with_label(Span::new_in(injected, 0, 1), "unregistered");
5614        sink.push_for(Some(Path::new("src/app/z_more.bynk")), error);
5615        let all = sink.into_all();
5616        assert_eq!(
5617            all[0].label_files,
5618            vec![
5619                LabelFile::Primary,
5620                LabelFile::Primary,
5621                LabelFile::Other(PathBuf::from("src/app/a_web.bynk")),
5622                LabelFile::Unavailable,
5623            ]
5624        );
5625    }
5626
5627    /// #1956: a label into a first-party unit carries that unit's reserved
5628    /// `FileId`, so it is attributed to the unit — with its own source — never
5629    /// taken for the error's file.
5630    #[test]
5631    fn a_label_in_a_firstparty_unit_is_attributed_to_that_unit() {
5632        let unit = firstparty::unit("bynk").expect("the `bynk` unit is registered");
5633        let own = FileId(30);
5634        let mut sink = ErrorSink::new();
5635        sink.register_file(own, PathBuf::from("src/app/web.bynk"));
5636        let error = CompileError::new(
5637            "bynk.resolve.missing_field",
5638            Span::new_in(own, 0, 7),
5639            "missing required field `headers` for record `Request`",
5640        )
5641        .with_label(
5642            Span::new_in(firstparty_file_id(unit), 0, 7),
5643            "field declared here",
5644        );
5645        sink.push_for(Some(Path::new("src/app/web.bynk")), error);
5646        assert_eq!(
5647            sink.into_all()[0].label_files,
5648            vec![LabelFile::FirstParty {
5649                identity_path: unit.identity_path,
5650                source: unit.source,
5651            }]
5652        );
5653    }
5654
5655    /// With no file identity on the primary span, a label is still the
5656    /// error's own file when its id names the error's own path.
5657    #[test]
5658    fn a_label_naming_the_errors_own_path_is_primary() {
5659        let own = FileId(20);
5660        let mut sink = ErrorSink::new();
5661        sink.register_file(own, PathBuf::from("src/a.bynk"));
5662        let error = CompileError::new("bynk.test", Span::default(), "msg")
5663            .with_label(Span::new_in(own, 0, 1), "here");
5664        sink.push_for(Some(Path::new("src/a.bynk")), error);
5665        assert_eq!(sink.into_all()[0].label_files, vec![LabelFile::Primary]);
5666    }
5667}
5668
5669/// #1925: a unit's own declaration of another kind shadowing a `uses`-imported
5670/// function or asset, and (#1927) a unit's own value shadowing an imported
5671/// type. A local declaration wins, as in the emitted module; a reference it
5672/// cannot answer, or a written hidden type name, is `bynk.uses.name_conflict`,
5673/// in a function body (the resolver's walk) and a handler body (the
5674/// checker's) alike.
5675#[cfg(test)]
5676mod shadowed_import_tests {
5677    use crate::testkit::analyse;
5678
5679    /// Each imported kind: its declaration in `lib.util`, and an expression
5680    /// using it with the type that expression has.
5681    const IMPORTED: [(&str, &str, &str, &str); 3] = [
5682        (
5683            "function",
5684            "fn thing() -> Int {\n  1\n}\n",
5685            "thing()",
5686            "Int",
5687        ),
5688        (
5689            "asset",
5690            "asset thing = \"assets/x.json\"\n",
5691            "thing.url",
5692            "String",
5693        ),
5694        // #1927: writing an imported type's name (here, constructing one).
5695        (
5696            "type",
5697            "type thing = { n: Int }\n",
5698            "thing { n: 1 }.n",
5699            "Int",
5700        ),
5701    ];
5702
5703    const LOCAL: [(&str, &str); 7] = [
5704        ("function", "fn thing() -> Int {\n  2\n}\n"),
5705        ("asset", "asset thing = \"assets/x.json\"\n"),
5706        ("type", "type thing = { n: Int }\n"),
5707        ("event", "event thing = { n: Int }\n"),
5708        (
5709            "service",
5710            "service thing {\n  on call() -> Effect[Int] {\n    Effect.pure(1)\n  }\n}\n",
5711        ),
5712        (
5713            "agent",
5714            "agent thing {\n  key id: String\n\n  store hits: Cell[Int]\n\n  on call ping() -> Effect[Int] {\n    1\n  }\n}\n",
5715        ),
5716        (
5717            "provider",
5718            "capability ProbeCap {\n  fn go() -> Effect[Int]\n}\n\nprovides ProbeCap = thing {\n  fn go() -> Effect[Int] {\n    Effect.pure(1)\n  }\n}\n",
5719        ),
5720    ];
5721
5722    #[derive(Clone, Copy, Debug)]
5723    enum Use {
5724        None,
5725        FnBody,
5726        HandlerBody,
5727    }
5728
5729    #[test]
5730    fn every_local_kind_against_every_import_and_use_site() {
5731        for (imported, idecl, expr, ty) in IMPORTED {
5732            for (local, ldecl) in LOCAL {
5733                for at in [Use::None, Use::FnBody, Use::HandlerBody] {
5734                    let using = match at {
5735                        Use::None => String::new(),
5736                        Use::FnBody => format!("fn useIt() -> {ty} {{\n  {expr}\n}}\n"),
5737                        Use::HandlerBody => format!(
5738                            "service user {{\n  on call() -> Effect[{ty}] {{\n    Effect.pure({expr})\n  }}\n}}\n"
5739                        ),
5740                    };
5741                    let lib = format!("commons lib.util\n\n{idecl}");
5742                    let app = format!("context app\n\nuses lib.util\n\n{ldecl}\n{using}");
5743                    let r = analyse(&[
5744                        ("lib/util.bynk", &lib),
5745                        ("app/main.bynk", &app),
5746                        ("assets/x.json", "{}"),
5747                    ]);
5748                    let cats = r.error_categories();
5749                    let case = format!("local {local} over imported {imported}, used {at:?}");
5750                    let want: Vec<&str> = match at {
5751                        // An unused shadow, and a same-kind one, are legal.
5752                        Use::None => vec![],
5753                        _ if local == imported => vec![],
5754                        // An event is a type: a local one over an imported
5755                        // type is #1824's type shadowing, and answers.
5756                        _ if imported == "type" && local == "event" => vec![],
5757                        // In a function body the resolver reads `thing.url`
5758                        // on a type name as a static member of the type, and
5759                        // rejects it under that code.
5760                        Use::FnBody if imported == "asset" && matches!(local, "type" | "event") => {
5761                            vec!["bynk.resolve.unknown_static_member"]
5762                        }
5763                        // A local agent answers a call in a handler (agent
5764                        // construction), wrongly here: one key, not none.
5765                        // Its diagnostic notes the hidden import.
5766                        Use::HandlerBody if imported == "function" && local == "agent" => {
5767                            vec!["bynk.agent.construction_arity"]
5768                        }
5769                        _ => vec!["bynk.uses.name_conflict"],
5770                    };
5771                    assert_eq!(cats, want, "{case}");
5772                }
5773            }
5774        }
5775    }
5776
5777    /// A local declaration that can answer the reference does: a call to a
5778    /// local agent is the agent, not the hidden import (before #1925 the
5779    /// checker preferred the imported function, against the emitted module).
5780    #[test]
5781    fn a_local_agent_answers_its_own_call() {
5782        let r = analyse(&[
5783            (
5784                "lib/util.bynk",
5785                "commons lib.util\n\nfn thing() -> Int {\n  1\n}\n",
5786            ),
5787            (
5788                "app/main.bynk",
5789                "context app\n\nuses lib.util\n\nagent thing {\n  key id: String\n\n  store hits: Cell[Int]\n\n  on call ping() -> Effect[Int] {\n    1\n  }\n}\n\nservice web {\n  on call() -> Effect[Int] {\n    let n <- thing(\"k\").ping()\n    Effect.pure(n)\n  }\n}\n",
5790            ),
5791        ]);
5792        r.assert_clean();
5793    }
5794
5795    /// Review of #1926: two used commons declaring one name are no ambiguity
5796    /// when the unit hides both behind its own declaration — an unused
5797    /// shadow stays legal (`phase_uses_name_conflicts` shares the predicate).
5798    #[test]
5799    fn a_local_service_hiding_two_used_imports_is_no_ambiguity() {
5800        let r = analyse(&[
5801            (
5802                "lib/a.bynk",
5803                "commons lib.a\n\nfn thing() -> Int {\n  1\n}\n",
5804            ),
5805            (
5806                "lib/b.bynk",
5807                "commons lib.b\n\nfn thing() -> Int {\n  2\n}\n",
5808            ),
5809            (
5810                "app/main.bynk",
5811                "context app\n\nuses lib.a\nuses lib.b\n\nservice thing {\n  on call() -> Effect[Int] {\n    Effect.pure(1)\n  }\n}\n",
5812            ),
5813        ]);
5814        r.assert_clean();
5815    }
5816
5817    /// Review of #1926: a bare reference to a local function that hides an
5818    /// imported asset means the function; only a field read (`thing.url`)
5819    /// means the hidden asset.
5820    #[test]
5821    fn a_bare_local_function_keeps_its_own_advice() {
5822        let lib = (
5823            "lib/util.bynk",
5824            "commons lib.util\n\nasset thing = \"assets/x.json\"\n",
5825        );
5826        let file = |body: &str| {
5827            format!(
5828                "context app\n\nuses lib.util\n\nfn thing() -> Int {{\n  1\n}}\n\nfn useIt() -> Int {{\n  {body}\n}}\n"
5829            )
5830        };
5831        let bare = file("thing + 1");
5832        let r = analyse(&[lib, ("app/main.bynk", &bare), ("assets/x.json", "{}")]);
5833        assert_eq!(r.error_categories(), vec!["bynk.resolve.fn_without_call"]);
5834        let read = file("let u = thing.url\n  1");
5835        let r = analyse(&[lib, ("app/main.bynk", &read), ("assets/x.json", "{}")]);
5836        assert_eq!(r.error_categories(), vec!["bynk.uses.name_conflict"]);
5837    }
5838
5839    /// #1927: the sum used across these tests, from a commons, with a
5840    /// function that makes one and one that reads one.
5841    const STATUS_LIB: (&str, &str) = (
5842        "lib/st.bynk",
5843        "commons lib.st\n\ntype Status =\n  | Done\n  | Pending\n  | Failed(reason: String)\n\nfn mk() -> Status {\n  Done\n}\n\nfn isDone(s: Status) -> Bool {\n  match s {\n    Done => true\n    _ => false\n  }\n}\n",
5844    );
5845
5846    /// #1927: a bare variant of a hidden sum emits the sum's name, which the
5847    /// local declaration has taken, so constructing one is the conflict.
5848    /// Matching one is not: a pattern emits the variant's tag alone.
5849    #[test]
5850    fn a_hidden_sums_bare_variant_constructs_only_by_conflict() {
5851        let unit = |body: &str| {
5852            format!("commons app\n\nuses lib.st\n\nfn Status() -> Int {{\n  1\n}}\n\n{body}")
5853        };
5854        let construct = unit("fn f() -> Bool {\n  isDone(Done)\n}\n");
5855        let r = analyse(&[STATUS_LIB, ("app/main.bynk", &construct)]);
5856        assert_eq!(r.error_categories(), vec!["bynk.uses.name_conflict"]);
5857        // A payload variant, constructed by a bare call.
5858        let construct = unit("fn f() -> Bool {\n  isDone(Failed(\"x\"))\n}\n");
5859        let r = analyse(&[STATUS_LIB, ("app/main.bynk", &construct)]);
5860        assert_eq!(r.error_categories(), vec!["bynk.uses.name_conflict"]);
5861        let matched = unit(
5862            "fn f() -> Bool {\n  match mk() {\n    Done => true\n    Failed(r) => false\n    _ => false\n  }\n}\n",
5863        );
5864        let r = analyse(&[STATUS_LIB, ("app/main.bynk", &matched)]);
5865        r.assert_clean();
5866    }
5867
5868    /// #1927: a value of a hidden type flows through the unit's code, and an
5869    /// imported declaration that uses the type still checks; only writing the
5870    /// name is the conflict.
5871    #[test]
5872    fn a_hidden_types_values_still_flow() {
5873        let r = analyse(&[
5874            STATUS_LIB,
5875            (
5876                "app/main.bynk",
5877                "context app\n\nuses lib.st\n\nagent Status {\n  key id: String\n\n  store hits: Cell[Int]\n\n  on call ping() -> Effect[Int] {\n    1\n  }\n}\n\nfn f() -> Bool {\n  let s = mk()\n  isDone(s)\n}\n",
5878            ),
5879        ]);
5880        r.assert_clean();
5881    }
5882
5883    /// #1927: a local asset answers its own `.url`, even beside a hidden
5884    /// imported type of its name.
5885    #[test]
5886    fn a_local_asset_answers_its_own_url_beside_a_hidden_type() {
5887        let r = analyse(&[
5888            STATUS_LIB,
5889            (
5890                "app/main.bynk",
5891                "commons app\n\nuses lib.st\n\nasset Status = \"assets/x.json\"\n\nfn f() -> String {\n  Status.url\n}\n",
5892            ),
5893            ("assets/x.json", "{}"),
5894        ]);
5895        r.assert_clean();
5896    }
5897
5898    /// Review of #1933: two used commons declaring the hidden name as a type
5899    /// and as a function. The local value hides both, and the type still
5900    /// gates the name whichever `uses` comes first.
5901    #[test]
5902    fn a_hidden_type_gates_whatever_the_uses_order() {
5903        let a = ("lib/a.bynk", "commons lib.a\n\ntype Status = { n: Int }\n");
5904        let b = (
5905            "lib/b.bynk",
5906            "commons lib.b\n\nfn Status() -> Int {\n  1\n}\n",
5907        );
5908        for (first, second) in [("lib.a", "lib.b"), ("lib.b", "lib.a")] {
5909            let app = format!(
5910                "context app\n\nuses {first}\nuses {second}\n\nservice Status {{\n  on call() -> Effect[Int] {{\n    Effect.pure(1)\n  }}\n}}\n\nfn f(s: Status) -> Int {{\n  s.n\n}}\n"
5911            );
5912            let r = analyse(&[a, b, ("app/main.bynk", &app)]);
5913            assert_eq!(
5914                r.error_categories(),
5915                vec!["bynk.uses.name_conflict"],
5916                "uses {first} then {second}"
5917            );
5918        }
5919    }
5920
5921    /// Review of #1933: a local function hiding an imported type answers a
5922    /// bare reference as a function value.
5923    #[test]
5924    fn a_local_function_hiding_a_type_is_a_function_value() {
5925        let r = analyse(&[
5926            (
5927                "lib/util.bynk",
5928                "commons lib.util\n\ntype thing = { n: Int }\n",
5929            ),
5930            (
5931                "app/main.bynk",
5932                "commons app\n\nuses lib.util\n\nfn thing(x: Int) -> Int {\n  x + 1\n}\n\nfn f() -> List[Int] {\n  [1, 2].map(thing)\n}\n",
5933            ),
5934        ]);
5935        r.assert_clean();
5936    }
5937
5938    /// #1927: a local value named like a type a context consumes conflicts at
5939    /// the `consumes` clause, as a local type of that name already does.
5940    #[test]
5941    fn a_local_value_named_like_a_consumed_export_conflicts() {
5942        let r = analyse(&[
5943            (
5944                "shop.bynk",
5945                "context shop\n\nexports transparent { Order }\n\ntype Order = { id: String }\n",
5946            ),
5947            (
5948                "app.bynk",
5949                "context app\n\nconsumes shop\n\nservice Order {\n  on call() -> Effect[Int] {\n    Effect.pure(1)\n  }\n}\n",
5950            ),
5951        ]);
5952        assert_eq!(r.error_categories(), vec!["bynk.consumes.name_conflict"]);
5953    }
5954
5955    /// The diagnostic names both declarations and where the local one is.
5956    #[test]
5957    fn the_conflict_names_both_declarations() {
5958        let r = analyse(&[
5959            (
5960                "lib/util.bynk",
5961                "commons lib.util\n\nfn thing() -> Int {\n  1\n}\n",
5962            ),
5963            (
5964                "app/main.bynk",
5965                "context app\n\nuses lib.util\n\ntype thing = { n: Int }\n\nfn useIt() -> Int {\n  thing()\n}\n",
5966            ),
5967        ]);
5968        let e = r
5969            .analysis
5970            .errors
5971            .iter()
5972            .find(|e| e.error.category == "bynk.uses.name_conflict")
5973            .expect("a conflict");
5974        assert_eq!(
5975            e.error.message,
5976            "`thing` names this unit's type, which hides the function `thing` from `lib.util`"
5977        );
5978    }
5979}
5980
5981#[cfg(test)]
5982mod firstparty_injection_tests {
5983    use super::{
5984        ErrorSink, ParsedFile, UnitKind, firstparty, firstparty_file_id, inject_firstparty, lexer,
5985        parser,
5986    };
5987    use bynk_syntax::span::FileId;
5988    use std::collections::BTreeSet;
5989    use std::path::PathBuf;
5990
5991    /// The seven references a project can make to a first-party unit, in a
5992    /// fixed order: `consumes` for the two adapters, `uses` for the commons.
5993    const REFS: [&str; 7] = [
5994        "consumes bynk { Clock }",
5995        "consumes bynk.cloudflare { Kv }",
5996        "uses bynk.map",
5997        "uses bynk.list",
5998        "uses bynk.string",
5999        "uses bynk.locale",
6000        "uses bynk.locale.types",
6001    ];
6002
6003    /// #1956: every injected first-party unit's spans carry its reserved
6004    /// `FileId` — distinct per unit and never `FileId::UNKNOWN` — so a label
6005    /// into one is never taken for the user's file.
6006    #[test]
6007    fn injected_units_carry_their_reserved_file_id() {
6008        let ids: BTreeSet<FileId> = firstparty::UNITS.iter().map(firstparty_file_id).collect();
6009        assert_eq!(ids.len(), firstparty::UNITS.len());
6010        assert!(!ids.contains(&FileId::UNKNOWN));
6011        let mut parsed = project((1 << REFS.len()) - 1);
6012        let injected = inject_firstparty(&mut parsed, &mut ErrorSink::new());
6013        assert!(!injected.is_empty());
6014        for unit in injected {
6015            let pf = parsed
6016                .iter()
6017                .find(|pf| pf.identity_path() == std::path::Path::new(unit.identity_path))
6018                .expect("an injected unit is in `parsed`");
6019            assert_eq!(
6020                pf.unit().span().file,
6021                firstparty_file_id(unit),
6022                "{}",
6023                unit.name
6024            );
6025        }
6026    }
6027
6028    /// A one-file project whose context makes the references `mask` selects.
6029    fn project(mask: u32) -> Vec<ParsedFile> {
6030        let mut uses = String::new();
6031        let mut consumes = String::new();
6032        for (bit, line) in REFS.iter().enumerate() {
6033            if mask & (1 << bit) != 0 {
6034                let into = if line.starts_with("uses") {
6035                    &mut uses
6036                } else {
6037                    &mut consumes
6038                };
6039                into.push_str(line);
6040                into.push('\n');
6041            }
6042        }
6043        let src = format!("context t\n\n{uses}{consumes}\nfn f() -> Int {{ 1 }}\n");
6044        let toks = lexer::tokenize(&src).expect("lexes");
6045        let (unit, _) =
6046            parser::parse_unit_with_warnings_from(&toks, &src, &mut { 0 }).expect("parses");
6047        vec![ParsedFile::synthetic(
6048            PathBuf::from("t.bynk"),
6049            PathBuf::from("t.bynk"),
6050            src,
6051            unit,
6052            UnitKind::Context,
6053        )]
6054    }
6055
6056    /// The units the hand-written injection (before S1, #1945) chose for the
6057    /// references in `mask`, written out from its gates.
6058    fn previous_injection(mask: u32) -> BTreeSet<&'static str> {
6059        let has = |bit: u32| mask & (1 << bit) != 0;
6060        let (bynk, cloudflare) = (has(0), has(1));
6061        let (map, list, string, locale, locale_types) = (has(2), has(3), has(4), has(5), has(6));
6062        let mut out = BTreeSet::new();
6063        let mut add = |cond: bool, name: &'static str| {
6064            if cond {
6065                out.insert(name);
6066            }
6067        };
6068        // Interop I5 (#1972): `bynk.cloudflare` consumes `bynk` for
6069        // `Service`'s `Request`/`Response`, so it brings `bynk` (and so its
6070        // `bynk.locale.types`) with it.
6071        let bynk = bynk || cloudflare;
6072        add(bynk, "bynk");
6073        add(cloudflare, "bynk.cloudflare");
6074        add(map, "bynk.map");
6075        add(map || locale || list, "bynk.list");
6076        add(locale || string, "bynk.string");
6077        // The `bynk` adapter's own `uses bynk.locale.types`, seen because the
6078        // adapter was injected before the scan ran.
6079        add(locale || locale_types || bynk, "bynk.locale.types");
6080        add(locale, "bynk.locale");
6081        out
6082    }
6083
6084    /// Following the sources' own `uses`/`consumes` injects exactly what the
6085    /// hand-written gates did, for every combination of references.
6086    #[test]
6087    fn the_closure_matches_the_previous_gates_for_every_combination() {
6088        for mask in 0..(1u32 << REFS.len()) {
6089            let mut parsed = project(mask);
6090            let mut errors = ErrorSink::new();
6091            let injected = inject_firstparty(&mut parsed, &mut errors);
6092            assert!(
6093                errors.is_empty(),
6094                "mask {mask:#09b}: a first-party source failed to parse"
6095            );
6096            let names: BTreeSet<&str> = injected.iter().map(|u| u.name).collect();
6097            assert_eq!(names, previous_injection(mask), "mask {mask:#09b}");
6098        }
6099    }
6100
6101    /// Injected units are pushed after the project's own, in ascending
6102    /// `id_block` order — the order the hand-written blocks pushed them in —
6103    /// and the returned entries follow the same order.
6104    #[test]
6105    fn injected_units_are_pushed_in_id_block_order() {
6106        let mut parsed = project((1 << REFS.len()) - 1);
6107        let mut errors = ErrorSink::new();
6108        let injected = inject_firstparty(&mut parsed, &mut errors);
6109        let pushed: Vec<String> = parsed[1..]
6110            .iter()
6111            .map(|pf| pf.unit().name().joined())
6112            .collect();
6113        assert_eq!(
6114            pushed,
6115            [
6116                "bynk",
6117                "bynk.cloudflare",
6118                "bynk.map",
6119                "bynk.list",
6120                "bynk.string",
6121                "bynk.locale.types",
6122                "bynk.locale",
6123            ]
6124        );
6125        let returned: Vec<&str> = injected.iter().map(|u| u.name).collect();
6126        assert_eq!(returned, pushed);
6127        assert!(injected.windows(2).all(|w| w[0].id_block < w[1].id_block));
6128    }
6129
6130    /// A project that references no first-party unit injects nothing.
6131    #[test]
6132    fn nothing_is_injected_unreferenced() {
6133        let mut parsed = project(0);
6134        let injected = inject_firstparty(&mut parsed, &mut ErrorSink::new());
6135        assert!(injected.is_empty());
6136        assert_eq!(parsed.len(), 1);
6137    }
6138
6139    /// The injection names no first-party unit: adding one to the standard
6140    /// library is a `bynk-std` registry entry plus its files (R-6). A per-unit
6141    /// constant reappearing here means a unit is being special-cased again.
6142    #[test]
6143    fn injection_is_registry_driven() {
6144        let src = include_str!("project_model.rs");
6145        let start = src.find("pub fn phase_parse(").expect("phase_parse");
6146        let end = src[start..]
6147            .find("\n/// #1710: for each unit with a file the strict parse rejected")
6148            .map(|rel| start + rel)
6149            .expect("the item after inject_firstparty");
6150        let body = &src[start..end];
6151        for name in firstparty::UNITS.iter().map(|u| u.name) {
6152            assert!(!body.contains(&format!("\"{name}\"")), "names {name}");
6153        }
6154        let per_unit_constant = body
6155            .lines()
6156            .find(|l| l.contains("firstparty::") && (l.contains("_UNIT") || l.contains("_SRC")));
6157        assert_eq!(per_unit_constant, None, "references a per-unit constant");
6158    }
6159}
6160
6161/// #2223: rewrite canonical dependency unit names in `error`'s text to the
6162/// form a person reads (`bynk_project::display_names`).
6163fn display_canonical_names(error: &mut CompileError) {
6164    if error.message.contains('$') {
6165        error.message = bynk_project::display_names(&error.message);
6166    }
6167    for note in &mut error.notes {
6168        if note.contains('$') {
6169            *note = bynk_project::display_names(note);
6170        }
6171    }
6172    for (_, label) in &mut error.labels {
6173        if label.contains('$') {
6174            *label = bynk_project::display_names(label);
6175        }
6176    }
6177}