Skip to main content

bynk_emit/
project.rs

1//! Multi-file project compilation (v0.3 §3.2 and §3.3, v0.4 §3.5).
2//!
3//! A "project" is a directory tree of `.bynk` source files. The dotted name
4//! of a commons or context (e.g., `bynk.time`, `commerce.orders`) maps to a
5//! path under the project root — either a single file (`bynk/time.bynk`) or
6//! a directory of files all sharing the same header (`bynk/time/*.bynk`).
7//!
8//! v0.4: each file is one of two kinds — commons or context. Both kinds share
9//! the same multi-file directory machinery; they differ in body content
10//! (contexts have `consumes`/`exports`, types are nominally per-context), in
11//! visibility (contexts export only the types listed), and in TypeScript
12//! emission (contexts re-brand types from used commons).
13//!
14//! Compilation proceeds in two passes:
15//!   1. **Discover and parse** every `.bynk` file. Group by qualified name
16//!      and kind. Build a global symbol table where each unit contributes
17//!      its declarations.
18//!   2. **Resolve, type-check, and emit** each unit with full visibility of
19//!      the units it transitively `uses` or `consumes`. Two passes keep
20//!      `uses` cycles trivial — there is no order-of-evaluation, only
21//!      declarative mixin.
22
23use std::collections::{BTreeSet, HashMap, HashSet};
24use std::fs;
25use std::path::{Component, Path, PathBuf};
26
27use crate::emitter;
28use bynk_check::checker;
29use bynk_check::checker::{CapabilityInfo, CapabilityOpInfo, Ty};
30use bynk_check::expr_types::{ExprTypeSink, FileExprTypes};
31use bynk_check::firstparty::{self, Platform};
32use bynk_check::hints::{FileHints, HintSink};
33use bynk_check::index::{IndexBuilder, ProjectIndex, RefSink, SiteRef, SymbolKind};
34use bynk_check::locals::{FileLocals, LocalsSink};
35use bynk_check::requirements::{FileRequirements, RequirementSink};
36use bynk_check::resolver::{self, MethodTable as ResolverMethodTable, ResolvedCommons};
37use bynk_syntax::ast::*;
38use bynk_syntax::error::CompileError;
39use bynk_syntax::lexer;
40use bynk_syntax::parser;
41use bynk_syntax::span::Span;
42
43mod consistency;
44mod diagnostics;
45mod discovery;
46mod graph;
47mod paths;
48mod symbols;
49mod tests_emit;
50mod validate;
51
52use consistency::*;
53use diagnostics::*;
54use discovery::*;
55use graph::*;
56use paths::*;
57use symbols::*;
58use tests_emit::*;
59use validate::*;
60
61// External facade: items referenced as `crate::project::X` from outside this
62// module (emitter, main, lib) must stay reachable at that path.
63pub use diagnostics::{AttributedError, ProjectAnalysis, ProjectFailure};
64pub use paths::{
65    ProjectPaths, read_project_paths, worker_dir_name, worker_handlers_output_path,
66    worker_handlers_source_path,
67};
68pub use symbols::{FileDeclIndex, UnitTable};
69pub use validate::check_function_type_boundary_items;
70pub(crate) use validate::type_ref_is_held;
71pub(crate) use validate::type_refs_match;
72
73/// One generated TypeScript file.
74pub struct CompiledFile {
75    /// The originating Bynk source file, relative to the project root.
76    pub source_path: PathBuf,
77    /// Where the TS output should be written, relative to the output root.
78    /// Mirrors the source tree, with `.bynk` rewritten to `.ts`.
79    pub output_path: PathBuf,
80    /// The emitted TypeScript content.
81    pub typescript: String,
82    /// Slice 1 (ADR 0103): the serialised source-map v3 document for this file,
83    /// when one was produced (the emitted `.bynk`-sourced units). `None` for
84    /// generated glue and config (runtime, compose, worker entry, `wrangler.toml`,
85    /// `package.json`, adapter bindings) — those have no `.bynk` to map back to.
86    /// `write_output` writes it as a sibling `.ts.map` and appends the
87    /// `//# sourceMappingURL` trailer; the in-memory `typescript` stays
88    /// trailer-free, so golden comparisons are unaffected.
89    pub source_map: Option<String>,
90    /// Slice 3 (semantic-debugging track, ADR 0105): the debug-metadata sidecar —
91    /// a JSON `{ fn → Bynk-operation-label }` map so the debugger names stack
92    /// frames `GET "/"` rather than `http_GET`. `Some` only for `.bynk` units that
93    /// declare handlers; `write_output` writes it as a sibling `.bynkdbg.json`.
94    pub debug_metadata: Option<String>,
95}
96
97/// Result of compiling a project.
98pub struct ProjectOutput {
99    pub files: Vec<CompiledFile>,
100    /// v0.89 (ADR 0117): non-failing warnings emitted on a successful build —
101    /// surfaced (the CLI prints them, the LSP shows them) but not gating.
102    pub warnings: Vec<AttributedError>,
103    /// v0.67: the test manifest — every discovered suite and case, retained at
104    /// emit time so `bynkc test --no-run --format json` can render a discovery
105    /// document without running the suite. Built from the same names + spans the
106    /// runner would emit at `suite-begin`/`case`, so a discovery document
107    /// reconciles cleanly against a later run's document (same suite name/kind,
108    /// same case names). Ordered to match the runner (`emit_test_main`).
109    pub discovered: Vec<DiscoveredSuite>,
110}
111
112/// v0.67: a discovered test suite — one `test <target>` group (unit) or
113/// `test integration "<suite>"` (integration). `name` + `kind` mirror exactly
114/// what the NDJSON runner emits at `suite-begin` (kind `"unit"` carries the
115/// joined target name; `"integration"` carries the bare suite name), so the
116/// editor reconciles discovery and run documents to the same tree items.
117#[derive(Debug, Clone, PartialEq)]
118pub struct DiscoveredSuite {
119    pub name: String,
120    pub kind: &'static str,
121    pub cases: Vec<DiscoveredCase>,
122}
123
124/// One discovered `test "<name>"` case. `location` points at the case-name
125/// literal (a run *failure* instead points at the failing `assert`), giving the
126/// editor click-through to the declaration before any run.
127#[derive(Debug, Clone, PartialEq)]
128pub struct DiscoveredCase {
129    pub name: String,
130    pub location: Option<TestLocation>,
131}
132
133/// A project-root-relative `path:line:col` source location, structured. Line and
134/// col are 1-indexed (the [`bynk_syntax::span::line_col`] convention).
135#[derive(Debug, Clone, PartialEq)]
136pub struct TestLocation {
137    pub path: String,
138    pub line: u32,
139    pub col: u32,
140}
141
142/// v0.17: a resolved adapter binding — the user-authored `.binding.ts` module
143/// that supplies an adapter's external provider symbols. Copied verbatim into
144/// the output beside the adapter's emitted interface module so that `tsc`
145/// checks the `implements` contract and compose can import the symbols.
146struct AdapterBinding {
147    /// Output path, relative to the output root (e.g. `tokens.binding.ts`).
148    output_path: PathBuf,
149    /// Verbatim TypeScript content read from the source tree.
150    content: String,
151}
152
153/// The build target. Determines how cross-context calls and per-context
154/// modules are emitted (v0.8). Bundle mode is the default — all contexts
155/// emit into one TypeScript bundle and cross-context calls are direct
156/// function invocations. Workers mode produces per-context Cloudflare
157/// Worker bundles that communicate via Service Bindings.
158#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
159pub enum BuildTarget {
160    /// Existing behaviour: one TS bundle, direct function calls between
161    /// contexts.
162    #[default]
163    Bundle,
164    /// One Worker per context. Cross-context calls become Service Binding
165    /// invocations using a JSON wire format with refinement validation on
166    /// the receiving side.
167    Workers,
168}
169
170/// Distinguishes a commons from a context (and from a test) in the project
171/// graph. Tests are a third kind in v0.7.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173pub enum UnitKind {
174    Commons,
175    Context,
176    Test,
177    /// v0.16: a `test integration` multi-Worker integration test.
178    Integration,
179    /// v0.17: an `adapter` — the host boundary (capability contract + binding).
180    Adapter,
181}
182
183impl UnitKind {
184    pub fn display(self) -> &'static str {
185        match self {
186            UnitKind::Commons => "commons",
187            UnitKind::Context => "context",
188            UnitKind::Test => "test",
189            UnitKind::Integration => "integration test",
190            UnitKind::Adapter => "adapter",
191        }
192    }
193}
194
195/// Where a project's `.bynk` files live.
196pub enum Roots {
197    /// A single tree walked as one root (in-memory builds and legacy
198    /// single-file/single-tree inputs).
199    Single(PathBuf),
200    /// v0.113 (DECISION S): a project rooted at `project_root`, with a flat
201    /// `include`/`exclude` layout (`ProjectPaths`). Test-ness is structural (a
202    /// `suite` declaration), so there is no source/test role split — the tree is
203    /// walked for `.bynk` files and each declaration is partitioned by kind.
204    Split {
205        project_root: PathBuf,
206        paths: ProjectPaths,
207    },
208}
209
210impl Roots {
211    /// Resolve to `(primary_root, secondary_root)` — the up-to-two `include`
212    /// trees walked for `.bynk` files. Most projects have a single root; a
213    /// conventional `src/`+`tests/` layout yields two. A file's identity path is
214    /// relative to the root that contains it.
215    fn resolve(&self) -> (PathBuf, PathBuf) {
216        match self {
217            Roots::Single(root) => (root.clone(), root.clone()),
218            Roots::Split {
219                project_root,
220                paths,
221            } => {
222                let primary = project_root.join(paths.include.first().cloned().unwrap_or_default());
223                let secondary = paths
224                    .include
225                    .get(1)
226                    .map(|p| project_root.join(p))
227                    .unwrap_or_else(|| primary.clone());
228                (primary, secondary)
229            }
230        }
231    }
232
233    /// The project-root-relative prefix of the **secondary** `include` root,
234    /// prepended to that tree's files' (root-relative) `source_path` so an
235    /// `expect`'s emitted `path:line:col` resolves from the project root (for
236    /// `--format json` click-through). Empty when there is a single root.
237    fn tests_prefix(&self) -> PathBuf {
238        match self {
239            Roots::Single(_) => PathBuf::new(),
240            Roots::Split { paths, .. } => paths.include.get(1).cloned().unwrap_or_default(),
241        }
242    }
243
244    /// Absolute subtrees to skip during discovery: the author's `exclude` list
245    /// plus the tool's own build-output and dependency caches (`out`,
246    /// `node_modules`), so a project whose `include` is the root does not sweep
247    /// up generated or vendored files.
248    fn excludes(&self) -> Vec<PathBuf> {
249        match self {
250            Roots::Single(_) => Vec::new(),
251            Roots::Split {
252                project_root,
253                paths,
254            } => {
255                let mut ex: Vec<PathBuf> =
256                    paths.exclude.iter().map(|p| project_root.join(p)).collect();
257                for cache in ["out", "node_modules"] {
258                    ex.push(project_root.join(cache));
259                }
260                ex
261            }
262        }
263    }
264}
265
266/// The extension emitted import specifiers use (`import … from "./x.<ext>"`).
267///
268/// `Js` is the default and the only shape for normal builds: NodeNext resolution
269/// and `tsc` require `.js` specifiers even though the sources are `.ts`. `Ts` is
270/// the **debug build** (slice 2, ADR 0104): `bynkc test --inspect` runs the
271/// emitted `.ts` directly under Node's line-preserving strip-only type-stripping,
272/// where slice 1's source maps apply unchanged — but Node will not resolve a `.js`
273/// specifier to the `.ts` on disk, so the debug build emits `.ts` specifiers.
274#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
275pub enum ImportExt {
276    #[default]
277    Js,
278    Ts,
279}
280
281impl ImportExt {
282    /// The bare extension string (`"js"` / `"ts"`), for `Path::with_extension`
283    /// and specifier formatting.
284    pub fn as_str(self) -> &'static str {
285        match self {
286            ImportExt::Js => "js",
287            ImportExt::Ts => "ts",
288        }
289    }
290}
291
292/// Options for [`compile_project`]. Construct with [`CompileOptions::single`] or
293/// [`CompileOptions::split`], then chain `.target(…)` / `.platform(…)` /
294/// `.import_ext(…)` to override the bundle/default-platform/`.js` defaults.
295pub struct CompileOptions {
296    pub target: BuildTarget,
297    pub platform: Platform,
298    pub roots: Roots,
299    /// The import-specifier extension (slice 2). `Js` (default) for normal builds;
300    /// `Ts` for the `bynkc test --inspect` debug build.
301    pub import_ext: ImportExt,
302}
303
304impl CompileOptions {
305    /// Single-root project (`src == tests`), bundle target, default platform.
306    pub fn single(root: impl Into<PathBuf>) -> Self {
307        Self {
308            target: BuildTarget::Bundle,
309            platform: Platform::default(),
310            roots: Roots::Single(root.into()),
311            import_ext: ImportExt::default(),
312        }
313    }
314
315    /// v0.9.1 split layout (source and test units in separate subdirectories
316    /// under `project_root`), bundle target, default platform. Use this from
317    /// `bynkc test` so its rooting matches `bynkc compile`'s.
318    pub fn split(project_root: impl Into<PathBuf>, paths: ProjectPaths) -> Self {
319        Self {
320            target: BuildTarget::Bundle,
321            platform: Platform::default(),
322            roots: Roots::Split {
323                project_root: project_root.into(),
324                paths,
325            },
326            import_ext: ImportExt::default(),
327        }
328    }
329
330    /// Select the build target. `Bundle` (default) is the v0.6+ single-bundle
331    /// layout; `Workers` (v0.8) emits per-context Cloudflare Workers.
332    pub fn target(mut self, target: BuildTarget) -> Self {
333        self.target = target;
334        self
335    }
336
337    /// Slice 2: select the import-specifier extension. `Ts` is the debug build
338    /// for `bynkc test --inspect` (run the `.ts` directly under Node strip-only).
339    pub fn import_ext(mut self, ext: ImportExt) -> Self {
340        self.import_ext = ext;
341        self
342    }
343
344    /// v0.17: select the deploy [`Platform`] (selects the `bynk` surface
345    /// binding). The MVP ships `cloudflare` only.
346    pub fn platform(mut self, platform: Platform) -> Self {
347        self.platform = platform;
348        self
349    }
350}
351
352/// Compile a Bynk project, keeping error attribution + snapshots on failure
353/// (so the CLI can render project errors with source context, ADR 0052). Use
354/// `.map_err(ProjectFailure::flatten)` for the flattened `Vec<CompileError>`
355/// shape.
356pub fn compile_project(options: &CompileOptions) -> Result<ProjectOutput, ProjectFailure> {
357    let (src_root, tests_root) = options.roots.resolve();
358    let tests_prefix = options.roots.tests_prefix();
359    let excludes = options.roots.excludes();
360    let run = run_checks(
361        &src_root,
362        &tests_root,
363        &tests_prefix,
364        options.target,
365        options.platform,
366        options.import_ext,
367        Mode::Build,
368        &HashMap::new(),
369        &excludes,
370        None,
371    );
372    finish_build(run, options.import_ext)
373}
374
375/// Compile a single **in-memory** Bynk source through the full project pipeline —
376/// no filesystem access (in-browser track, slice 3). The source is the in-process
377/// `Bundle` subset that `consumes bynk`; first-party injection and the per-platform
378/// binding emission run exactly as for an on-disk build, so the returned
379/// [`ProjectOutput`] is the complete module graph (the user unit + `runtime.ts` +
380/// the `bynk-<platform>.ts` binding + `compose.ts`). The wasm entry point pairs
381/// this with `bynk-strip` to produce JavaScript for the playground.
382///
383/// The module's logical path is **derived from its declared unit name** (a context
384/// `app.demo` ⇒ `app/demo.bynk`), so the name↔path alignment check passes without
385/// real files; a source that does not parse falls back to `main.bynk` and the parse
386/// error is reported normally.
387pub fn compile_in_memory(
388    source: &str,
389    target: BuildTarget,
390    platform: Platform,
391) -> Result<ProjectOutput, ProjectFailure> {
392    // A single-tree (`src_root == tests_root`) virtual project rooted at `.`: the
393    // one source file is supplied directly and its text layered in via the
394    // overlay, so discovery and every other disk read are bypassed.
395    let root = PathBuf::from(".");
396    let path = in_memory_logical_path(source);
397    let mut overlay = HashMap::new();
398    overlay.insert(path.clone(), source.to_string());
399    let run = run_checks(
400        &root,
401        &root,
402        &root,
403        target,
404        platform,
405        ImportExt::Js,
406        Mode::Build,
407        &overlay,
408        &[],
409        Some((vec![path], Vec::new())),
410    );
411    finish_build(run, ImportExt::Js)
412}
413
414/// Analyse a single **in-memory** Bynk source and return all diagnostics —
415/// non-bailing, no emission (in-browser track, slice 5d). The editor calls this
416/// on every (debounced) keystroke for live diagnostics: unlike [`compile_in_memory`]
417/// (build mode, which bails at the first failing phase), this runs in `Analyse`
418/// mode, so parse / resolve / check diagnostics are recovered and reported together
419/// — and it works for a `context` (the playground's typical program), not only a
420/// commons. Same fs-free seam as `compile_in_memory`.
421pub fn analyse_in_memory(
422    source: &str,
423    target: BuildTarget,
424    platform: Platform,
425) -> Vec<AttributedError> {
426    let root = PathBuf::from(".");
427    let path = in_memory_logical_path(source);
428    let mut overlay = HashMap::new();
429    overlay.insert(path.clone(), source.to_string());
430    let run = run_checks(
431        &root,
432        &root,
433        &root,
434        target,
435        platform,
436        ImportExt::Js,
437        Mode::Analyse,
438        &overlay,
439        &[],
440        Some((vec![path], Vec::new())),
441    );
442    match run {
443        RunChecks::Bailed { errors, .. } | RunChecks::Checked { errors, .. } => errors.into_all(),
444    }
445}
446
447/// Derive the conventional single-file path for an in-memory source from its
448/// declared unit name (`app.demo` ⇒ `app/demo.bynk`), so `check_path_name_alignment`
449/// is satisfied without a real file tree. Falls back to `main.bynk` when the source
450/// does not parse — `run_checks` then re-parses and reports the error against it.
451fn in_memory_logical_path(source: &str) -> PathBuf {
452    let parts: Option<Vec<String>> = lexer::tokenize(source)
453        .ok()
454        .and_then(|tokens| parser::parse_unit(&tokens, source).ok())
455        .map(|unit| {
456            let name = match &unit {
457                SourceUnit::Commons(c) => &c.name,
458                SourceUnit::Context(c) => &c.name,
459                SourceUnit::Adapter(a) => &a.name,
460                SourceUnit::Suite(t) => &t.target,
461                SourceUnit::Integration(i) => &i.name,
462            };
463            name.parts.iter().map(|i| i.name.clone()).collect()
464        });
465    match parts {
466        Some(p) if !p.is_empty() => {
467            let mut path = PathBuf::from(p.join("/"));
468            path.set_extension("bynk");
469            path
470        }
471        _ => PathBuf::from("main.bynk"),
472    }
473}
474
475/// Assemble a finished [`ProjectOutput`] (or a [`ProjectFailure`]) from a
476/// [`RunChecks`] result — the shared tail of `compile_project` and
477/// `compile_in_memory`.
478fn finish_build(run: RunChecks, import_ext: ImportExt) -> Result<ProjectOutput, ProjectFailure> {
479    match run {
480        RunChecks::Bailed {
481            errors, snapshots, ..
482        } => Err(ProjectFailure {
483            // ADR 0117: a failed build still renders any warnings it produced
484            // (the sink yields errors then warnings).
485            errors: errors.into_all(),
486            snapshots,
487        }),
488        RunChecks::Checked {
489            errors, snapshots, ..
490        } if !errors.is_empty() => Err(ProjectFailure {
491            errors: errors.into_all(),
492            snapshots,
493        }),
494        RunChecks::Checked {
495            errors,
496            compiled,
497            runnable_tests,
498            integration_outputs,
499            integration_runnables,
500            groups,
501            kinds,
502            unit_consumes,
503            unit_consumes_aliases,
504            unit_tables,
505            unit_flattened,
506            adapter_bindings,
507            npm_deps,
508            target,
509            ..
510        } => {
511            let mut out = build_output(
512                compiled,
513                runnable_tests,
514                integration_outputs,
515                integration_runnables,
516                groups,
517                kinds,
518                unit_consumes,
519                unit_consumes_aliases,
520                unit_tables,
521                unit_flattened,
522                adapter_bindings,
523                npm_deps,
524                target,
525                import_ext,
526            );
527            // ADR 0117: surface non-failing warnings on the successful build
528            // (errors is empty here — the guard arm above caught any).
529            out.warnings = errors.into_warnings();
530            Ok(out)
531        }
532    }
533}
534
535/// v0.24: analyse a project without building — non-bailing, overlay-aware,
536/// file-attributed (ADR 0052). `overlay` maps canonicalised absolute paths
537/// to buffer text layered over disk reads (unsaved editor buffers).
538pub fn analyse_project(root: &Path, overlay: &HashMap<PathBuf, String>) -> ProjectAnalysis {
539    match run_checks(
540        root,
541        root,
542        Path::new(""),
543        BuildTarget::Bundle,
544        Platform::default(),
545        ImportExt::Js,
546        Mode::Analyse,
547        overlay,
548        &[],
549        None,
550    ) {
551        RunChecks::Bailed {
552            errors,
553            snapshots,
554            mut hints,
555            mut locals,
556            mut exprs,
557            mut requirements,
558        } => ProjectAnalysis {
559            snapshots,
560            // ADR 0117: the LSP renders warnings alongside errors (severity is
561            // applied downstream), so analyse surfaces the full diagnostic list.
562            errors: errors.into_all(),
563            index: ProjectIndex::default(),
564            hints: hints.take_files(),
565            locals: locals.take_files(),
566            expr_types: exprs.take_files(),
567            requirements: requirements.take_files(),
568            // No parsed tree on the bail path — the map stays empty (ADR 0095).
569            unit_sources: HashMap::new(),
570        },
571        RunChecks::Checked {
572            errors,
573            snapshots,
574            mut refs,
575            mut hints,
576            mut locals,
577            mut exprs,
578            mut requirements,
579            parsed,
580            unit_uses,
581            unit_consumes,
582            ..
583        } => {
584            let index = assemble_index(
585                &parsed,
586                &unit_uses,
587                &unit_consumes,
588                std::mem::take(&mut refs),
589            );
590            // ADR 0095: qualified unit name → its project source file(s), in
591            // discovery order. Synthetic (toolchain-injected `bynk` surface)
592            // units have no openable file and are excluded.
593            let mut unit_sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
594            for pf in &parsed {
595                if pf.synthetic {
596                    continue;
597                }
598                unit_sources
599                    .entry(pf.unit.name().joined())
600                    .or_default()
601                    .push(pf.source_path.clone());
602            }
603            ProjectAnalysis {
604                snapshots,
605                errors: errors.into_all(),
606                index,
607                hints: hints.take_files(),
608                locals: locals.take_files(),
609                expr_types: exprs.take_files(),
610                requirements: requirements.take_files(),
611                unit_sources,
612            }
613        }
614    }
615}
616
617/// Record a file's (possibly partial) expression types into the Analyse-mode
618/// sink. Called at every per-file exit in the check loop so `.`-member completion
619/// and signature help get the receiver's type even when a later check phase
620/// errors for the file (ADR 0094). A no-op-shaped wrapper, factored out so the
621/// four exits share one call.
622fn record_analyse_types(
623    exprs: &mut ExprTypeSink,
624    source_path: &Path,
625    synthetic: bool,
626    types: &HashMap<Span, Ty>,
627) {
628    exprs.enter_file(source_path, synthetic);
629    exprs.record_file(types);
630}
631
632/// Phase 1: discover the `.bynk` files under the source (and, in split mode,
633/// the tests) root, and run the file-vs-directory conflict checks. Pushes any
634/// discovery errors into `errors` and signals a pipeline bail via `Err(())`
635/// (the caller terminates with `finish`); otherwise returns the discovered
636/// `(src_files, tests_files)`.
637fn phase_discovery(
638    src_root: &Path,
639    tests_root: &Path,
640    split_mode: bool,
641    excludes: &[PathBuf],
642    errors: &mut ErrorSink,
643) -> Result<(Vec<PathBuf>, Vec<PathBuf>), ()> {
644    let src_files = match discover_bynk_files(src_root, excludes) {
645        Ok(f) => f,
646        Err(e) => {
647            errors.push_for(None, e);
648            return Err(());
649        }
650    };
651    let tests_files = if split_mode {
652        // The secondary `include` root is optional — a project may have no such
653        // tree. A missing directory is not an error.
654        if tests_root.exists() {
655            match discover_bynk_files(tests_root, excludes) {
656                Ok(f) => f,
657                Err(e) => {
658                    errors.push_for(None, e);
659                    return Err(());
660                }
661            }
662        } else {
663            Vec::new()
664        }
665    } else {
666        Vec::new()
667    };
668    if src_files.is_empty() && tests_files.is_empty() {
669        errors.push_for(
670            None,
671            CompileError::new(
672                "bynk.project.no_sources",
673                Span::default(),
674                format!("no `.bynk` source files found under {}", src_root.display()),
675            ),
676        );
677        return Err(());
678    }
679    if let Err(e) = check_file_directory_conflicts(src_root, &src_files) {
680        errors.extend_for(None, e);
681    }
682    if split_mode && let Err(e) = check_file_directory_conflicts(tests_root, &tests_files) {
683        errors.extend_for(None, e);
684    }
685    Ok((src_files, tests_files))
686}
687
688/// Phase 2: parse every discovered file into a `ParsedFile`, recording each
689/// file's source text into `snapshots` and any parse errors into `errors`.
690/// Then inject the first-party synthetic units (the `bynk`/`bynk.cloudflare`
691/// adapters and the `bynk.{list,map,string}` commons) that the project
692/// consumes/uses. Returns the parsed units plus whether the `bynk` and
693/// `bynk.cloudflare` adapters were injected; signals a pipeline bail via
694/// `Err(())` when parsing produced errors and yielded no units at all.
695#[allow(clippy::too_many_arguments)]
696fn phase_parse(
697    src_root: &Path,
698    tests_root: &Path,
699    split_mode: bool,
700    src_files: &[PathBuf],
701    tests_files: &[PathBuf],
702    overlay: &HashMap<PathBuf, String>,
703    errors: &mut ErrorSink,
704    snapshots: &mut Vec<(PathBuf, String)>,
705) -> Result<(Vec<ParsedFile>, bool, bool), ()> {
706    let mut parsed: Vec<ParsedFile> = Vec::new();
707    let parse_tree = |root: &Path,
708                      files: &[PathBuf],
709                      parsed: &mut Vec<ParsedFile>,
710                      errors: &mut ErrorSink,
711                      snapshots: &mut Vec<(PathBuf, String)>| {
712        for path in files {
713            let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
714            let source = match read_source(path, overlay) {
715                Ok(s) => s,
716                Err(e) => {
717                    errors.push_for(
718                        Some(&rel),
719                        CompileError::new(
720                            "bynk.project.read_failed",
721                            Span::default(),
722                            format!("could not read `{}`: {e}", path.display()),
723                        ),
724                    );
725                    continue;
726                }
727            };
728            snapshots.push((rel.clone(), source.clone()));
729            match parse_sources(root, path, source) {
730                Ok(pfs) => parsed.extend(pfs),
731                Err(errs) => errors.extend_for(Some(&rel), errs),
732            }
733        }
734    };
735    parse_tree(src_root, src_files, &mut parsed, errors, snapshots);
736    if split_mode {
737        parse_tree(tests_root, tests_files, &mut parsed, errors, snapshots);
738    }
739    if !errors.is_empty() && parsed.is_empty() {
740        return Err(());
741    }
742
743    // v0.17: if any user unit consumes the first-party `bynk` surface, inject it
744    // as a synthetic adapter so it flows through the normal pipeline (tables,
745    // exports, emission, compose). Its binding is supplied by the toolchain for
746    // the selected platform (§4.2). Injected only when consumed, so adapter-free
747    // projects are unchanged.
748    let consumes_bynk = parsed.iter().any(|pf| {
749        pf.consumes()
750            .iter()
751            .any(|c| c.target.joined() == firstparty::BYNK_UNIT)
752    });
753    if consumes_bynk {
754        match lexer::tokenize(firstparty::BYNK_ADAPTER_SRC)
755            .map_err(|e| vec![e])
756            .and_then(|toks| parser::parse_unit(&toks, firstparty::BYNK_ADAPTER_SRC))
757        {
758            Ok(unit) => parsed.push(ParsedFile {
759                source_path: PathBuf::from("bynk.bynk"),
760                abs_path: None,
761                source: firstparty::BYNK_ADAPTER_SRC.to_string(),
762                unit,
763                kind: UnitKind::Adapter,
764                synthetic: true,
765            }),
766            Err(errs) => errors.extend_for(None, errs),
767        }
768    }
769    // v0.19: likewise the first-party `bynk.cloudflare` platform adapter —
770    // injected only when consumed, binding supplied by the toolchain. The
771    // unit name sits inside the reserved `bynk.*` prefix (decision 0026).
772    let consumes_cloudflare = parsed.iter().any(|pf| {
773        pf.consumes()
774            .iter()
775            .any(|c| c.target.joined() == firstparty::CLOUDFLARE_UNIT)
776    });
777    if consumes_cloudflare {
778        match lexer::tokenize(firstparty::CLOUDFLARE_ADAPTER_SRC)
779            .map_err(|e| vec![e])
780            .and_then(|toks| parser::parse_unit(&toks, firstparty::CLOUDFLARE_ADAPTER_SRC))
781        {
782            Ok(unit) => parsed.push(ParsedFile {
783                source_path: PathBuf::from("bynk/cloudflare.bynk"),
784                abs_path: None,
785                source: firstparty::CLOUDFLARE_ADAPTER_SRC.to_string(),
786                unit,
787                kind: UnitKind::Adapter,
788                synthetic: true,
789            }),
790            Err(errs) => errors.extend_for(None, errs),
791        }
792    }
793    // v0.20b: the first-party collection commons. Unlike the adapters above
794    // these are *library* units — plain Bynk commons of generic functions —
795    // imported via `uses` rather than `consumes`, and injected the same way
796    // so they flow through the ordinary commons pipeline (tables, uses
797    // resolution, emission). `bynk.map` itself `uses bynk.list`, so using
798    // the former injects both.
799    let uses_unit = |parsed: &[ParsedFile], unit: &str| {
800        parsed
801            .iter()
802            .any(|pf| pf.uses().iter().any(|u| u.target.joined() == unit))
803    };
804    let uses_map = uses_unit(&parsed, firstparty::MAP_UNIT);
805    if uses_map {
806        match lexer::tokenize(firstparty::BYNK_MAP_SRC)
807            .map_err(|e| vec![e])
808            .and_then(|toks| parser::parse_unit(&toks, firstparty::BYNK_MAP_SRC))
809        {
810            Ok(unit) => parsed.push(ParsedFile {
811                source_path: PathBuf::from("bynk/map.bynk"),
812                abs_path: None,
813                source: firstparty::BYNK_MAP_SRC.to_string(),
814                unit,
815                kind: UnitKind::Commons,
816                synthetic: true,
817            }),
818            Err(errs) => errors.extend_for(None, errs),
819        }
820    }
821    if uses_map || uses_unit(&parsed, firstparty::LIST_UNIT) {
822        match lexer::tokenize(firstparty::BYNK_LIST_SRC)
823            .map_err(|e| vec![e])
824            .and_then(|toks| parser::parse_unit(&toks, firstparty::BYNK_LIST_SRC))
825        {
826            Ok(unit) => parsed.push(ParsedFile {
827                source_path: PathBuf::from("bynk/list.bynk"),
828                abs_path: None,
829                source: firstparty::BYNK_LIST_SRC.to_string(),
830                unit,
831                kind: UnitKind::Commons,
832                synthetic: true,
833            }),
834            Err(errs) => errors.extend_for(None, errs),
835        }
836    }
837    // v0.22a: the first-party string commons — derived helpers over the
838    // built-in string kernel (ADR 0046).
839    if uses_unit(&parsed, firstparty::STRING_UNIT) {
840        match lexer::tokenize(firstparty::BYNK_STRING_SRC)
841            .map_err(|e| vec![e])
842            .and_then(|toks| parser::parse_unit(&toks, firstparty::BYNK_STRING_SRC))
843        {
844            Ok(unit) => parsed.push(ParsedFile {
845                source_path: PathBuf::from("bynk/string.bynk"),
846                abs_path: None,
847                source: firstparty::BYNK_STRING_SRC.to_string(),
848                unit,
849                kind: UnitKind::Commons,
850                synthetic: true,
851            }),
852            Err(errs) => errors.extend_for(None, errs),
853        }
854    }
855
856    Ok((parsed, consumes_bynk, consumes_cloudflare))
857}
858
859/// Phase 3: group the parsed units by qualified name (production units, unit
860/// tests, and integration suites tracked separately), run the per-directory
861/// and path/name consistency checks, enforce the reserved `bynk` namespace and
862/// the adapter `binding` rules, resolve each adapter's binding module, and fold
863/// the adapters' pinned npm dependencies. Pushes diagnostics into `errors` and
864/// returns the production `groups`/`kinds`, the `test`/`integration` groups, the
865/// resolved `adapter_bindings`, and the collected `npm_deps`.
866#[allow(clippy::type_complexity)]
867fn phase_group(
868    parsed: &[ParsedFile],
869    src_root: &Path,
870    platform: Platform,
871    consumes_bynk: bool,
872    consumes_cloudflare: bool,
873    errors: &mut ErrorSink,
874) -> (
875    HashMap<String, Vec<usize>>,
876    HashMap<String, UnitKind>,
877    HashMap<String, Vec<usize>>,
878    HashMap<String, Vec<usize>>,
879    HashMap<String, AdapterBinding>,
880    std::collections::BTreeMap<String, String>,
881) {
882    // Tests (v0.7) are tracked separately from production units. Their
883    // `target` joined-name can intentionally coincide with a commons or
884    // context name; they don't enter the production groups/kinds maps.
885    let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
886    let mut kinds: HashMap<String, UnitKind> = HashMap::new();
887    let mut test_groups: HashMap<String, Vec<usize>> = HashMap::new();
888    // v0.16: integration tests are tracked by suite name, separately again from
889    // unit tests — their `name()` is the synthetic `integration <suite>`.
890    let mut integration_groups: HashMap<String, Vec<usize>> = HashMap::new();
891    for (i, pf) in parsed.iter().enumerate() {
892        let name = pf.unit.name().joined();
893        if pf.kind == UnitKind::Integration {
894            integration_groups.entry(name).or_default().push(i);
895        } else if pf.kind == UnitKind::Test {
896            test_groups.entry(name).or_default().push(i);
897        } else {
898            groups.entry(name.clone()).or_default().push(i);
899            kinds.entry(name).or_insert(pf.kind);
900        }
901    }
902    if let Err(e) = check_directory_name_consistency(parsed) {
903        errors.extend_for(None, e);
904    }
905    if let Err(e) = check_directory_kind_consistency(parsed) {
906        errors.extend_for(None, e);
907    }
908    // A group must agree on kind across all its files (different name but
909    // same kind is fine; same name but different kind is an error).
910    if let Err(e) = check_group_kind_consistency(parsed, &groups) {
911        errors.extend_for(None, e);
912    }
913    // Each *source* unit's file path must match its declared qualified name.
914    // v0.113 (DECISION S): a `suite` has no path-identity requirement — it names
915    // its target and is legal in any file — so test-ness carries no path check.
916    if let Err(e) = check_path_name_alignment(parsed) {
917        errors.extend_for(None, e);
918    }
919
920    // v0.20a: function types are confined to non-boundary positions.
921    let mut fn_boundary_errors: Vec<CompileError> = Vec::new();
922    check_function_type_boundaries(parsed, &mut fn_boundary_errors);
923    errors.extend_for(None, fn_boundary_errors);
924
925    // v0.17: the `bynk` root namespace is reserved for the toolchain. No user
926    // unit of any kind may be named `bynk` or `bynk.*` (§3.4).
927    for pf in parsed {
928        if pf.synthetic {
929            continue;
930        }
931        let qn = pf.unit.name();
932        if qn.parts.first().is_some_and(|p| p.name == "bynk") {
933            errors.push_for(None,
934                CompileError::new(
935                    "bynk.namespace.reserved",
936                    qn.span,
937                    format!(
938                        "`{}` uses the reserved `bynk` namespace — the `bynk` root is reserved for the toolchain's conformance surface",
939                        qn.joined()
940                    ),
941                )
942                .with_note("rename the unit so its first segment is not `bynk`"),
943            );
944        }
945    }
946
947    // v0.17: an adapter that declares any external provider must name a
948    // `binding` module to supply the implementation symbols (§3.5). First-party
949    // (synthetic) adapters omit the clause — the toolchain supplies the binding.
950    for pf in parsed {
951        if pf.synthetic {
952            continue;
953        }
954        if let Some(a) = pf.adapter() {
955            let has_external = a
956                .items
957                .iter()
958                .any(|it| matches!(it, CommonsItem::Provider(p) if p.external));
959            if has_external && a.binding.is_none() {
960                errors.push_for(None,
961                    CompileError::new(
962                        "bynk.adapter.no_binding",
963                        a.span,
964                        format!(
965                            "adapter `{}` declares an external provider but has no `binding` clause to supply its implementation",
966                            a.name.joined()
967                        ),
968                    )
969                    .with_note(
970                        "add a `binding \"<module>\"` clause naming the TypeScript module that exports the provider symbols",
971                    ),
972                );
973            }
974        }
975    }
976
977    // v0.17: resolve each adapter's binding module (relative to the adapter's
978    // source file) and read it, so compose can import the external provider
979    // symbols and the binding is copied into the output for the `tsc` gate.
980    let mut adapter_bindings: HashMap<String, AdapterBinding> = HashMap::new();
981    // v0.17: the toolchain supplies the `bynk` surface's binding, platform-keyed.
982    if consumes_bynk {
983        adapter_bindings.insert(
984            firstparty::BYNK_UNIT.to_string(),
985            AdapterBinding {
986                output_path: PathBuf::from(platform.bynk_binding_filename()),
987                content: platform.bynk_binding_source().to_string(),
988            },
989        );
990    }
991    // v0.19: the platform adapter's binding is single — it runs only on its
992    // own platform (the lock check rejects other `--platform` selections).
993    if consumes_cloudflare {
994        adapter_bindings.insert(
995            firstparty::CLOUDFLARE_UNIT.to_string(),
996            AdapterBinding {
997                output_path: PathBuf::from(firstparty::CLOUDFLARE_BINDING_FILENAME),
998                content: firstparty::cloudflare_binding_source().to_string(),
999            },
1000        );
1001    }
1002    for pf in parsed {
1003        let Some(a) = pf.adapter() else { continue };
1004        let Some(b) = &a.binding else { continue };
1005        let adapter_dir = pf.source_path.parent().unwrap_or(Path::new(""));
1006        let out_rel = normalize_rel(&adapter_dir.join(&b.module));
1007        let src_abs = src_root.join(&out_rel);
1008        match fs::read_to_string(&src_abs) {
1009            Ok(content) => {
1010                adapter_bindings.insert(
1011                    a.name.joined(),
1012                    AdapterBinding {
1013                        output_path: out_rel,
1014                        content,
1015                    },
1016                );
1017            }
1018            Err(e) => {
1019                errors.push_for(None,
1020                    CompileError::new(
1021                        "bynk.adapter.no_binding",
1022                        b.module_span,
1023                        format!(
1024                            "adapter `{}` names binding module `{}`, which could not be read ({e})",
1025                            a.name.joined(),
1026                            b.module
1027                        ),
1028                    )
1029                    .with_note(
1030                        "the binding path is resolved relative to the adapter's source file; author the `.binding.ts` there",
1031                    ),
1032                );
1033            }
1034        }
1035    }
1036
1037    // v0.17: collect adapter npm dependencies for `package.json`, rejecting
1038    // unpinned ranges ([DECISION L] stub — fold + pin-check only, no allow-list).
1039    let mut npm_deps: std::collections::BTreeMap<String, String> =
1040        std::collections::BTreeMap::new();
1041    for pf in parsed {
1042        let Some(a) = pf.adapter() else { continue };
1043        let Some(b) = &a.binding else { continue };
1044        for dep in &b.requires {
1045            if is_unpinned_range(&dep.range) {
1046                errors.push_for(None,
1047                    CompileError::new(
1048                        "bynk.requires.unpinned_dependency",
1049                        dep.span,
1050                        format!(
1051                            "dependency `{}` has an unpinned version range `{}` — pin a concrete range (e.g. `^1.2.0`)",
1052                            dep.package, dep.range
1053                        ),
1054                    )
1055                    .with_note(
1056                        "unpinned ranges (`*`, `latest`, …) make builds irreproducible and are rejected",
1057                    ),
1058                );
1059                continue;
1060            }
1061            npm_deps.insert(dep.package.clone(), dep.range.clone());
1062        }
1063    }
1064
1065    (
1066        groups,
1067        kinds,
1068        test_groups,
1069        integration_groups,
1070        adapter_bindings,
1071        npm_deps,
1072    )
1073}
1074
1075/// Phase 4: build each production unit's combined symbol table from its files,
1076/// pushing any table-construction errors into `errors`.
1077fn phase_symbol_tables(
1078    groups: &HashMap<String, Vec<usize>>,
1079    kinds: &HashMap<String, UnitKind>,
1080    parsed: &[ParsedFile],
1081    errors: &mut ErrorSink,
1082) -> HashMap<String, UnitTable> {
1083    let mut unit_tables: HashMap<String, UnitTable> = HashMap::new();
1084    for (name, indices) in groups {
1085        let kind = *kinds.get(name).expect("every group has a kind");
1086        let mut table_errors: Vec<CompileError> = Vec::new();
1087        let table = build_unit_table(name, kind, indices, parsed, &mut table_errors);
1088        errors.extend_for(None, table_errors);
1089        unit_tables.insert(name.clone(), table);
1090    }
1091    unit_tables
1092}
1093
1094/// Phase 5: resolve each unit's `uses` clauses, checking the target exists, is
1095/// a commons, and is not self-referential. Returns unit → deduplicated list of
1096/// used commons; diagnostics go into `errors`.
1097fn phase_resolve_uses(
1098    groups: &HashMap<String, Vec<usize>>,
1099    kinds: &HashMap<String, UnitKind>,
1100    parsed: &[ParsedFile],
1101    unit_tables: &HashMap<String, UnitTable>,
1102    errors: &mut ErrorSink,
1103) -> HashMap<String, Vec<String>> {
1104    let mut unit_uses: HashMap<String, Vec<String>> = HashMap::new();
1105    for (name, indices) in groups {
1106        let mut uses_targets: Vec<String> = Vec::new();
1107        for &i in indices {
1108            for u in parsed[i].uses() {
1109                let target = u.target.joined();
1110                if !unit_tables.contains_key(&target) {
1111                    errors.push_for(
1112                        None,
1113                        CompileError::new(
1114                            "bynk.uses.unknown_commons",
1115                            u.span,
1116                            format!("unknown commons `{target}`"),
1117                        )
1118                        .with_note(
1119                            "the target of a `uses` clause must be a commons in the project",
1120                        ),
1121                    );
1122                    continue;
1123                }
1124                let target_kind = *kinds.get(&target).unwrap();
1125                if target_kind != UnitKind::Commons {
1126                    errors.push_for(None,
1127                        CompileError::new(
1128                            "bynk.uses.target_is_context",
1129                            u.span,
1130                            format!(
1131                                "`uses {target}` targets a context — `uses` may only target a commons"
1132                            ),
1133                        )
1134                        .with_note(
1135                            "to declare a dependency on a context, use `consumes` instead",
1136                        ),
1137                    );
1138                    continue;
1139                }
1140                if target == *name {
1141                    errors.push_for(
1142                        None,
1143                        CompileError::new(
1144                            "bynk.uses.self_reference",
1145                            u.span,
1146                            format!("`{name}` cannot `uses` itself"),
1147                        ),
1148                    );
1149                    continue;
1150                }
1151                if !uses_targets.contains(&target) {
1152                    uses_targets.push(target);
1153                }
1154            }
1155        }
1156        unit_uses.insert(name.clone(), uses_targets);
1157    }
1158    unit_uses
1159}
1160
1161/// Phase 5b: resolve each unit's `consumes` clauses (target exists, is a context
1162/// or adapter, not self-referential, obeys the adapter selection rules), and for
1163/// the braced `consumes U { Cap, … }` form validate and record the flattened
1164/// capabilities. Returns unit → consumed targets and unit → flattened-cap → owning
1165/// unit; diagnostics go into `errors` and clause-position references into `refs`.
1166#[allow(clippy::type_complexity)]
1167fn phase_resolve_consumes(
1168    groups: &HashMap<String, Vec<usize>>,
1169    kinds: &HashMap<String, UnitKind>,
1170    parsed: &[ParsedFile],
1171    unit_tables: &HashMap<String, UnitTable>,
1172    errors: &mut ErrorSink,
1173    refs: &mut RefSink,
1174) -> (
1175    HashMap<String, Vec<String>>,
1176    HashMap<String, HashMap<String, String>>,
1177) {
1178    let mut unit_consumes: HashMap<String, Vec<String>> = HashMap::new();
1179    // v0.17: `consumes U { Cap, … }` flattens selected caps into the consumer's
1180    // local namespace. unit → bare-cap → consumed unit providing it.
1181    let mut unit_flattened: HashMap<String, HashMap<String, String>> = HashMap::new();
1182    for (name, indices) in groups {
1183        let kind = *kinds.get(name).unwrap();
1184        let mut consumes_targets: Vec<String> = Vec::new();
1185        let mut flattened: HashMap<String, String> = HashMap::new();
1186        let local_caps: HashSet<String> = unit_tables
1187            .get(name)
1188            .map(|t| t.capabilities.keys().cloned().collect())
1189            .unwrap_or_default();
1190        for &i in indices {
1191            refs.enter_file(&parsed[i].source_path, name, parsed[i].synthetic);
1192            for c in parsed[i].consumes() {
1193                let target = c.target.joined();
1194                if kind != UnitKind::Context && kind != UnitKind::Adapter {
1195                    errors.push_for(None,
1196                        CompileError::new(
1197                            "bynk.consumes.in_commons",
1198                            c.span,
1199                            format!(
1200                                "`consumes` is only valid inside a context or adapter, not a commons `{name}`",
1201                            ),
1202                        )
1203                        .with_note(
1204                            "commons declare vocabulary; only contexts and adapters can declare behavioural dependencies",
1205                        ),
1206                    );
1207                    continue;
1208                }
1209                // v0.18: an adapter's `consumes` is the braced capability-selection
1210                // form only — an adapter has no services to RPC-call, so the
1211                // whole-unit and `as Alias` forms are meaningless inside one.
1212                if kind == UnitKind::Adapter && c.selected.is_none() {
1213                    errors.push_for(None,
1214                        CompileError::new(
1215                            "bynk.adapter.consumes_requires_selection",
1216                            c.span,
1217                            format!(
1218                                "an adapter's `consumes` must select capabilities — write `consumes {target} {{ Cap, … }}`",
1219                            ),
1220                        )
1221                        .with_note(
1222                            "adapters depend on capabilities, never on services; the whole-unit and aliased forms are context-only",
1223                        ),
1224                    );
1225                    continue;
1226                }
1227                if !unit_tables.contains_key(&target) {
1228                    errors.push_for(
1229                        None,
1230                        CompileError::new(
1231                            "bynk.consumes.unknown_context",
1232                            c.span,
1233                            format!("unknown context `{target}`"),
1234                        )
1235                        .with_note(
1236                            "the target of a `consumes` clause must be a context in the project",
1237                        ),
1238                    );
1239                    continue;
1240                }
1241                let target_kind = *kinds.get(&target).unwrap();
1242                // v0.17: `consumes` may target a context or an adapter (the host
1243                // boundary). It may not target a commons (use `uses` for that).
1244                if target_kind != UnitKind::Context && target_kind != UnitKind::Adapter {
1245                    errors.push_for(None,
1246                        CompileError::new(
1247                            "bynk.consumes.target_is_commons",
1248                            c.span,
1249                            format!(
1250                                "`consumes {target}` targets a commons — `consumes` may only target a context or adapter"
1251                            ),
1252                        )
1253                        .with_note(
1254                            "to mix in declarations from a commons, use `uses` instead",
1255                        ),
1256                    );
1257                    continue;
1258                }
1259                // v0.18: adapter dependencies are adapter-to-adapter (spec §4.5) —
1260                // an adapter consuming a *context* would pull service logic into
1261                // the host boundary.
1262                if kind == UnitKind::Adapter && target_kind == UnitKind::Context {
1263                    errors.push_for(None,
1264                        CompileError::new(
1265                            "bynk.adapter.consumes_context",
1266                            c.span,
1267                            format!(
1268                                "adapter `{name}` cannot `consumes` the context `{target}` — adapter dependencies are adapter-to-adapter"
1269                            ),
1270                        )
1271                        .with_note(
1272                            "an adapter may only depend on capabilities exported by other adapters (e.g. the `bynk` surface)",
1273                        ),
1274                    );
1275                    continue;
1276                }
1277                if target == *name {
1278                    let kind_word = if kind == UnitKind::Adapter {
1279                        "adapter"
1280                    } else {
1281                        "context"
1282                    };
1283                    errors.push_for(
1284                        None,
1285                        CompileError::new(
1286                            "bynk.consumes.self_reference",
1287                            c.span,
1288                            format!("{kind_word} `{name}` cannot `consumes` itself"),
1289                        ),
1290                    );
1291                    continue;
1292                }
1293                // v0.17: `consumes U { Cap, … }` — validate each selected name is
1294                // a capability `U` exports, detect clashes, and record the
1295                // flattening so bare `given Cap` resolves through the local path.
1296                if let Some(names) = &c.selected {
1297                    let exported = unit_tables
1298                        .get(&target)
1299                        .map(|t| &t.exported_capabilities)
1300                        .cloned()
1301                        .unwrap_or_default();
1302                    for cap in names {
1303                        if !exported.contains(&cap.name) {
1304                            errors.push_for(
1305                                None,
1306                                CompileError::new(
1307                                    "bynk.given.cross_context_unknown_capability",
1308                                    cap.span,
1309                                    format!(
1310                                        "`{target}` does not export a capability named `{}`",
1311                                        cap.name
1312                                    ),
1313                                ),
1314                            );
1315                            continue;
1316                        }
1317                        if local_caps.contains(&cap.name) {
1318                            errors.push_for(None, CompileError::new(
1319                                "bynk.consumes.capability_name_clash",
1320                                cap.span,
1321                                format!(
1322                                    "flattened capability `{}` clashes with a capability declared locally — use qualified `given {target}.{}` instead",
1323                                    cap.name, cap.name
1324                                ),
1325                            ));
1326                            continue;
1327                        }
1328                        if let Some(prev) = flattened.get(&cap.name) {
1329                            errors.push_for(None, CompileError::new(
1330                                "bynk.consumes.capability_name_clash",
1331                                cap.span,
1332                                format!(
1333                                    "capability `{}` is flattened from both `{prev}` and `{target}` — qualify one with `given U.{}`",
1334                                    cap.name, cap.name
1335                                ),
1336                            ));
1337                            continue;
1338                        }
1339                        // v0.25: the selection list names the capability in
1340                        // the consumed unit (clause-position reference).
1341                        refs.record_in_unit(cap.span, SymbolKind::Capability, &cap.name, &target);
1342                        flattened.insert(cap.name.clone(), target.clone());
1343                    }
1344                }
1345                if !consumes_targets.contains(&target) {
1346                    consumes_targets.push(target);
1347                }
1348            }
1349        }
1350        unit_consumes.insert(name.clone(), consumes_targets);
1351        unit_flattened.insert(name.clone(), flattened);
1352    }
1353    (unit_consumes, unit_flattened)
1354}
1355
1356/// Phases 5b'/5b'': collect each context's `consumes` aliases (alias →
1357/// consumed-context name), reporting alias-vs-alias conflicts (5b'), then report
1358/// any alias that clashes with a locally-declared type/fn/capability/service/agent
1359/// (5b''). Returns the per-context alias maps; diagnostics go into `errors`.
1360fn phase_consumes_aliases(
1361    groups: &HashMap<String, Vec<usize>>,
1362    kinds: &HashMap<String, UnitKind>,
1363    parsed: &[ParsedFile],
1364    unit_tables: &HashMap<String, UnitTable>,
1365    errors: &mut ErrorSink,
1366) -> HashMap<String, HashMap<String, String>> {
1367    let mut unit_consumes_aliases: HashMap<String, HashMap<String, String>> = HashMap::new();
1368    for (name, indices) in groups {
1369        let kind = *kinds.get(name).unwrap();
1370        if kind != UnitKind::Context {
1371            continue;
1372        }
1373        let mut aliases: HashMap<String, String> = HashMap::new();
1374        let mut alias_spans: HashMap<String, Span> = HashMap::new();
1375        for &i in indices {
1376            for c in parsed[i].consumes() {
1377                let Some(alias) = &c.alias else { continue };
1378                let target = c.target.joined();
1379                if !unit_tables.contains_key(&target) {
1380                    // Already reported as unknown context above.
1381                    continue;
1382                }
1383                if let Some(prev_span) = alias_spans.get(&alias.name) {
1384                    errors.push_for(None,
1385                        CompileError::new(
1386                            "bynk.consumes.alias_conflict",
1387                            alias.span,
1388                            format!(
1389                                "alias `{}` is used by more than one `consumes` clause in context `{}`",
1390                                alias.name, name
1391                            ),
1392                        )
1393                        .with_label(*prev_span, "previously defined here")
1394                        .with_note(
1395                            "each `consumes` clause may introduce at most one alias, and aliases must be unique within a context",
1396                        ),
1397                    );
1398                    continue;
1399                }
1400                aliases.insert(alias.name.clone(), target);
1401                alias_spans.insert(alias.name.clone(), alias.span);
1402            }
1403        }
1404        unit_consumes_aliases.insert(name.clone(), aliases);
1405    }
1406
1407    // -- 5b''. Detect alias-vs-local-decl conflicts. An alias must not clash
1408    //          with any locally declared type/fn/capability/service/agent.
1409    for (name, aliases) in &unit_consumes_aliases {
1410        let Some(local) = unit_tables.get(name) else {
1411            continue;
1412        };
1413        for alias in aliases.keys() {
1414            let alias_span = parsed_alias_span(parsed, &groups[name], alias).unwrap_or_default();
1415            let conflict_kind = if local.types.contains_key(alias) {
1416                Some("type")
1417            } else if local.fns.contains_key(alias) {
1418                Some("function")
1419            } else if local.capabilities.contains_key(alias) {
1420                Some("capability")
1421            } else if local.services.contains_key(alias) {
1422                Some("service")
1423            } else if local.agents.contains_key(alias) {
1424                Some("agent")
1425            } else {
1426                None
1427            };
1428            if let Some(kind) = conflict_kind {
1429                errors.push_for(None,
1430                    CompileError::new(
1431                        "bynk.consumes.alias_conflict",
1432                        alias_span,
1433                        format!(
1434                            "alias `{alias}` conflicts with a local {kind} of the same name in context `{name}`",
1435                        ),
1436                    )
1437                    .with_note(
1438                        "pick a different alias for the `consumes` clause, or rename the local declaration",
1439                    ),
1440                );
1441            }
1442        }
1443    }
1444    unit_consumes_aliases
1445}
1446
1447/// Phase 6: for each unit, detect when two `uses`-imported commons declare the
1448/// same (non-shadowed) type or function name — an unrenamable conflict at the use
1449/// site. Diagnostics go into `errors`.
1450fn phase_uses_name_conflicts(
1451    unit_uses: &HashMap<String, Vec<String>>,
1452    unit_tables: &HashMap<String, UnitTable>,
1453    parsed: &[ParsedFile],
1454    groups: &HashMap<String, Vec<usize>>,
1455    errors: &mut ErrorSink,
1456) {
1457    for (name, targets) in unit_uses {
1458        let local = unit_tables.get(name).expect("unit table present");
1459        let mut imported: HashMap<String, String> = HashMap::new();
1460        for t in targets {
1461            let used = unit_tables.get(t).expect("used unit table present");
1462            for type_name in used.types.keys() {
1463                if local.types.contains_key(type_name) || local.fns.contains_key(type_name) {
1464                    continue;
1465                }
1466                if let Some(prev) = imported.get(type_name) {
1467                    let span = uses_span_of(parsed, &groups[name], t).unwrap_or_default();
1468                    errors.push_for(None,
1469                        CompileError::new(
1470                            "bynk.uses.name_conflict",
1471                            span,
1472                            format!(
1473                                "`{name}` uses two commons that both declare `{type_name}`: `{prev}` and `{t}`",
1474                            ),
1475                        )
1476                        .with_note(
1477                            "name conflicts at the use site are not yet renamable; remove or restructure one of the imports",
1478                        ),
1479                    );
1480                } else {
1481                    imported.insert(type_name.clone(), t.clone());
1482                }
1483            }
1484            for fn_name in used.fns.keys() {
1485                if local.types.contains_key(fn_name) || local.fns.contains_key(fn_name) {
1486                    continue;
1487                }
1488                if let Some(prev) = imported.get(fn_name) {
1489                    let span = uses_span_of(parsed, &groups[name], t).unwrap_or_default();
1490                    errors.push_for(None,
1491                        CompileError::new(
1492                            "bynk.uses.name_conflict",
1493                            span,
1494                            format!(
1495                                "`{name}` uses two commons that both declare `{fn_name}`: `{prev}` and `{t}`",
1496                            ),
1497                        )
1498                        .with_note(
1499                            "name conflicts at the use site are not yet renamable; remove or restructure one of the imports",
1500                        ),
1501                    );
1502                } else {
1503                    imported.insert(fn_name.clone(), t.clone());
1504                }
1505            }
1506        }
1507    }
1508}
1509
1510/// Phase 6b: validate each context/adapter's `exports opaque/transparent { … }`
1511/// clauses — every name must be a locally-declared type, with no duplicates
1512/// within a clause or conflicting visibilities across clauses. Returns unit →
1513/// (type → visibility); diagnostics go into `errors` and export references into
1514/// `refs`.
1515fn phase_validate_type_exports(
1516    groups: &HashMap<String, Vec<usize>>,
1517    kinds: &HashMap<String, UnitKind>,
1518    parsed: &[ParsedFile],
1519    unit_tables: &HashMap<String, UnitTable>,
1520    errors: &mut ErrorSink,
1521    refs: &mut RefSink,
1522) -> HashMap<String, HashMap<String, Visibility>> {
1523    let mut exports_visibility: HashMap<String, HashMap<String, Visibility>> = HashMap::new();
1524    for (name, indices) in groups {
1525        let kind = *kinds.get(name).unwrap();
1526        if kind != UnitKind::Context && kind != UnitKind::Adapter {
1527            // Commons may not have exports clauses (parsed grammar prevents it
1528            // at the parser level), but in case any sneak in, skip.
1529            continue;
1530        }
1531        let local = unit_tables.get(name).unwrap();
1532        let mut seen: HashMap<String, (Visibility, Span)> = HashMap::new();
1533        for &i in indices {
1534            refs.enter_file(&parsed[i].source_path, name, parsed[i].synthetic);
1535            for clause in parsed[i].exports() {
1536                // v0.15: `exports capability { ... }` clauses are validated
1537                // separately (§4.1); 6b handles only type exports.
1538                let ExportKind::Type(clause_vis) = clause.kind else {
1539                    continue;
1540                };
1541                let mut within: HashMap<String, Span> = HashMap::new();
1542                for n in &clause.names {
1543                    if let Some(prev) = within.get(&n.name) {
1544                        errors.push_for(
1545                            None,
1546                            CompileError::new(
1547                                "bynk.exports.duplicate_in_clause",
1548                                n.span,
1549                                format!(
1550                                    "type `{}` appears more than once in this exports clause",
1551                                    n.name
1552                                ),
1553                            )
1554                            .with_label(*prev, "previously listed here"),
1555                        );
1556                        continue;
1557                    }
1558                    within.insert(n.name.clone(), n.span);
1559
1560                    if !local.types.contains_key(&n.name) {
1561                        errors.push_for(None,
1562                            CompileError::new(
1563                                "bynk.exports.undeclared_type",
1564                                n.span,
1565                                format!(
1566                                    "exports clause references `{}`, which is not a type declared in context `{}`",
1567                                    n.name, name
1568                                ),
1569                            )
1570                            .with_note(
1571                                "only types declared in the same context can appear in `exports` clauses",
1572                            ),
1573                        );
1574                        continue;
1575                    }
1576                    // v0.25: `exports opaque/transparent { T }` names the type.
1577                    refs.record(n.span, SymbolKind::Type, &n.name);
1578
1579                    if let Some((prev_vis, prev_span)) = seen.get(&n.name) {
1580                        if *prev_vis == clause_vis {
1581                            errors.push_for(
1582                                None,
1583                                CompileError::new(
1584                                    "bynk.exports.duplicate_export",
1585                                    n.span,
1586                                    format!("type `{}` is exported more than once", n.name),
1587                                )
1588                                .with_label(*prev_span, "previously exported here"),
1589                            );
1590                        } else {
1591                            errors.push_for(None,
1592                                CompileError::new(
1593                                    "bynk.exports.conflicting_visibility",
1594                                    n.span,
1595                                    format!(
1596                                        "type `{}` is exported with conflicting visibilities — pick `opaque` or `transparent`",
1597                                        n.name,
1598                                    ),
1599                                )
1600                                .with_label(*prev_span, "previously exported here"),
1601                            );
1602                        }
1603                        continue;
1604                    }
1605                    seen.insert(n.name.clone(), (clause_vis, n.span));
1606                }
1607            }
1608        }
1609        let mut visibility_map: HashMap<String, Visibility> = HashMap::new();
1610        for (n, (v, _)) in seen {
1611            visibility_map.insert(n, v);
1612        }
1613        exports_visibility.insert(name.clone(), visibility_map);
1614    }
1615    exports_visibility
1616}
1617
1618/// Phase 6b': validate each context/adapter's `exports capability { … }` clauses
1619/// (v0.15 §4.1) — every name must be a capability the unit declares *and*
1620/// provides, with no duplicate exports. Diagnostics go into `errors` and export
1621/// references into `refs`.
1622fn phase_validate_capability_exports(
1623    groups: &HashMap<String, Vec<usize>>,
1624    kinds: &HashMap<String, UnitKind>,
1625    parsed: &[ParsedFile],
1626    unit_tables: &HashMap<String, UnitTable>,
1627    errors: &mut ErrorSink,
1628    refs: &mut RefSink,
1629) {
1630    for (name, indices) in groups {
1631        if kinds.get(name) != Some(&UnitKind::Context)
1632            && kinds.get(name) != Some(&UnitKind::Adapter)
1633        {
1634            continue;
1635        }
1636        let local = unit_tables.get(name).unwrap();
1637        let mut seen: HashMap<String, Span> = HashMap::new();
1638        for &i in indices {
1639            refs.enter_file(&parsed[i].source_path, name, parsed[i].synthetic);
1640            for clause in parsed[i].exports() {
1641                if !matches!(clause.kind, ExportKind::Capability) {
1642                    continue;
1643                }
1644                for n in &clause.names {
1645                    if let Some(prev) = seen.get(&n.name) {
1646                        errors.push_for(
1647                            None,
1648                            CompileError::new(
1649                                "bynk.exports.duplicate_export",
1650                                n.span,
1651                                format!("capability `{}` is exported more than once", n.name),
1652                            )
1653                            .with_label(*prev, "previously exported here"),
1654                        );
1655                        continue;
1656                    }
1657                    seen.insert(n.name.clone(), n.span);
1658                    if local.capabilities.contains_key(&n.name) {
1659                        // v0.25: `exports capability { Cap }` names the
1660                        // capability.
1661                        refs.record(n.span, SymbolKind::Capability, &n.name);
1662                    }
1663                    if !local.capabilities.contains_key(&n.name) {
1664                        errors.push_for(None,
1665                            CompileError::new(
1666                                "bynk.exports.undeclared_capability",
1667                                n.span,
1668                                format!(
1669                                    "`exports capability` references `{}`, which is not a capability declared in context `{}`",
1670                                    n.name, name
1671                                ),
1672                            )
1673                            .with_note(
1674                                "only capabilities declared in the same context can appear in `exports capability` clauses",
1675                            ),
1676                        );
1677                        continue;
1678                    }
1679                    if !local.providers.contains_key(&n.name) {
1680                        errors.push_for(None,
1681                            CompileError::new(
1682                                "bynk.exports.capability_not_provided",
1683                                n.span,
1684                                format!(
1685                                    "exported capability `{}` has no provider in context `{}` — a consumer cannot instantiate it",
1686                                    n.name, name
1687                                ),
1688                            )
1689                            .with_note(
1690                                "add a `provides {n} = …` declaration so the capability can be wired into consumers",
1691                            ),
1692                        );
1693                    }
1694                }
1695            }
1696        }
1697    }
1698}
1699
1700/// Phase 6c: validate that every (non-external) provider matches its capability
1701/// exactly — each capability op has a provider op, and every provider op has a
1702/// matching capability op with the same parameter and return types. Diagnostics
1703/// go into `errors`.
1704fn phase_validate_providers(unit_tables: &HashMap<String, UnitTable>, errors: &mut ErrorSink) {
1705    for (name, table) in unit_tables {
1706        let _ = name;
1707        for (cap_name, provider) in &table.providers {
1708            // v0.17: an external provider has no Bynk body to match against the
1709            // capability — its implementation is the binding, checked by `tsc`.
1710            if provider.external {
1711                continue;
1712            }
1713            let Some(cap) = table.capabilities.get(cap_name) else {
1714                errors.push_for(None,
1715                    CompileError::new(
1716                        "bynk.provider.unknown_capability",
1717                        provider.capability.span,
1718                        format!(
1719                            "provider targets unknown capability `{}` — declare the capability in the same context",
1720                            cap_name
1721                        ),
1722                    ),
1723                );
1724                continue;
1725            };
1726            // 1) Every capability op has a provider op.
1727            for cap_op in &cap.ops {
1728                if !provider.ops.iter().any(|o| o.name.name == cap_op.name.name) {
1729                    errors.push_for(
1730                        None,
1731                        CompileError::new(
1732                            "bynk.provider.missing_operation",
1733                            provider.span,
1734                            format!(
1735                                "provider `{}` for capability `{}` is missing operation `{}`",
1736                                provider.provider_name.name, cap_name, cap_op.name.name
1737                            ),
1738                        ),
1739                    );
1740                }
1741            }
1742            // 2) Every provider op corresponds to a capability op with the
1743            //    same signature (param types and return type).
1744            for prov_op in &provider.ops {
1745                let Some(cap_op) = cap.ops.iter().find(|o| o.name.name == prov_op.name.name) else {
1746                    errors.push_for(None, CompileError::new(
1747                        "bynk.provider.extra_operation",
1748                        prov_op.span,
1749                        format!(
1750                            "provider operation `{}.{}` does not match any operation in capability `{}`",
1751                            provider.provider_name.name, prov_op.name.name, cap_name
1752                        ),
1753                    ));
1754                    continue;
1755                };
1756                if cap_op.params.len() != prov_op.params.len() {
1757                    errors.push_for(None, CompileError::new(
1758                        "bynk.provider.signature_mismatch",
1759                        prov_op.span,
1760                        format!(
1761                            "provider operation `{}.{}` has {} parameter(s), but capability operation expects {}",
1762                            provider.provider_name.name,
1763                            prov_op.name.name,
1764                            prov_op.params.len(),
1765                            cap_op.params.len()
1766                        ),
1767                    ));
1768                    continue;
1769                }
1770                for (i, (cap_p, prov_p)) in
1771                    cap_op.params.iter().zip(prov_op.params.iter()).enumerate()
1772                {
1773                    if !type_refs_match(&cap_p.type_ref, &prov_p.type_ref) {
1774                        errors.push_for(None, CompileError::new(
1775                            "bynk.provider.signature_mismatch",
1776                            prov_p.span,
1777                            format!(
1778                                "provider operation `{}.{}` parameter {} has type `{}`, but capability declares `{}`",
1779                                provider.provider_name.name,
1780                                prov_op.name.name,
1781                                i + 1,
1782                                ts_type_ref_display(&prov_p.type_ref),
1783                                ts_type_ref_display(&cap_p.type_ref)
1784                            ),
1785                        ));
1786                    }
1787                }
1788                if !type_refs_match(&cap_op.return_type, &prov_op.return_type) {
1789                    errors.push_for(None, CompileError::new(
1790                        "bynk.provider.signature_mismatch",
1791                        prov_op.return_type.span(),
1792                        format!(
1793                            "provider operation `{}.{}` returns `{}`, but capability declares `{}`",
1794                            provider.provider_name.name,
1795                            prov_op.name.name,
1796                            ts_type_ref_display(&prov_op.return_type),
1797                            ts_type_ref_display(&cap_op.return_type)
1798                        ),
1799                    ));
1800                }
1801            }
1802        }
1803    }
1804}
1805
1806/// Phase 7: build each production unit's file-declaration index (which file in
1807/// the unit declares which name), for cross-file lookups in the back half.
1808fn phase_file_index(
1809    groups: &HashMap<String, Vec<usize>>,
1810    parsed: &[ParsedFile],
1811) -> HashMap<String, FileDeclIndex> {
1812    let mut unit_file_index: HashMap<String, FileDeclIndex> = HashMap::new();
1813    for (name, indices) in groups {
1814        unit_file_index.insert(name.clone(), build_file_decl_index(indices, parsed));
1815    }
1816    unit_file_index
1817}
1818
1819/// v0.29.4: the per-unit facets that the producer phases build as nine parallel
1820/// `HashMap<String, _>`s, all keyed on unit name. Assembling one record per unit
1821/// makes the "all these maps share one keyset" invariant structural: a single
1822/// lookup yields every facet as a field, so the per-column `.unwrap()`s on the
1823/// shared keyset disappear. Fields are total — `exports`/`aliases`/`flattened`
1824/// default to an empty map for a unit with no entry, reproducing the old
1825/// `.unwrap_or(empty)` read semantics without the dance.
1826struct UnitInfo {
1827    kind: UnitKind,
1828    table: UnitTable,
1829    uses: Vec<String>,
1830    consumes: Vec<String>,
1831    flattened: HashMap<String, String>,
1832    aliases: HashMap<String, String>,
1833    exports: HashMap<String, Visibility>,
1834    file_index: FileDeclIndex,
1835    files: Vec<usize>,
1836}
1837
1838/// v0.29.4: fold the nine parallel per-unit maps into one `HashMap<String,
1839/// UnitInfo>`. Assembly is driven by the `groups` keyset (the authority), so
1840/// every group yields exactly one record. Facets that are genuinely optional in
1841/// the producer maps (`exports`/`aliases`/`flattened`, and `file_index` for a
1842/// unit with no declarations) default to empty — reproducing the old
1843/// `.unwrap_or(empty)` read semantics as a total field.
1844#[allow(clippy::too_many_arguments)]
1845fn assemble_unit_info(
1846    groups: &HashMap<String, Vec<usize>>,
1847    kinds: &HashMap<String, UnitKind>,
1848    unit_tables: &HashMap<String, UnitTable>,
1849    unit_uses: &HashMap<String, Vec<String>>,
1850    unit_consumes: &HashMap<String, Vec<String>>,
1851    unit_flattened: &HashMap<String, HashMap<String, String>>,
1852    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
1853    exports_visibility: &HashMap<String, HashMap<String, Visibility>>,
1854    unit_file_index: &HashMap<String, FileDeclIndex>,
1855) -> HashMap<String, UnitInfo> {
1856    groups
1857        .iter()
1858        .map(|(name, indices)| {
1859            let info = UnitInfo {
1860                kind: *kinds.get(name).unwrap(),
1861                table: unit_tables.get(name).unwrap().clone(),
1862                uses: unit_uses.get(name).cloned().unwrap_or_default(),
1863                consumes: unit_consumes.get(name).cloned().unwrap_or_default(),
1864                flattened: unit_flattened.get(name).cloned().unwrap_or_default(),
1865                aliases: unit_consumes_aliases.get(name).cloned().unwrap_or_default(),
1866                exports: exports_visibility.get(name).cloned().unwrap_or_default(),
1867                file_index: unit_file_index
1868                    .get(name)
1869                    .cloned()
1870                    .unwrap_or_else(|| FileDeclIndex {
1871                        types: HashMap::new(),
1872                        fns: HashMap::new(),
1873                        methods: HashMap::new(),
1874                    }),
1875                files: indices.clone(),
1876            };
1877            (name.clone(), info)
1878        })
1879        .collect()
1880}
1881
1882/// Phase 8c: collect every method authored anywhere in one unit, keyed by its
1883/// attached type's name — so a type's methods surface in the file that declares
1884/// the type even when the method lives in a sibling file. The collection loop
1885/// has no `continue`s, so it lifts out whole.
1886fn collect_unit_methods(indices: &[usize], parsed: &[ParsedFile]) -> HashMap<String, Vec<FnDecl>> {
1887    let mut local_methods_for_type: HashMap<String, Vec<FnDecl>> = HashMap::new();
1888    for &j in indices {
1889        for item in parsed[j].items() {
1890            if let CommonsItem::Fn(f) = item
1891                && let FnName::Method { type_name, .. } = &f.name
1892            {
1893                local_methods_for_type
1894                    .entry(type_name.name.clone())
1895                    .or_default()
1896                    .push(f.clone());
1897            }
1898        }
1899    }
1900    local_methods_for_type
1901}
1902
1903/// Phase 8b: merge one context's `consumes` exports into the composed symbol
1904/// space, recording visibility metadata in the returned `consumed_types`. The
1905/// per-export `continue`s (missing decl, name conflict) stay internal to the
1906/// loop, which lifts out whole; name conflicts are pushed into `errors` and the
1907/// caller's `group_error_baseline` guard reacts to them after this returns.
1908#[allow(clippy::too_many_arguments)]
1909fn merge_consumed_exports(
1910    name: &str,
1911    parsed: &[ParsedFile],
1912    unit_info: &HashMap<String, UnitInfo>,
1913    combined_types: &mut HashMap<String, TypeDecl>,
1914    combined_methods: &mut HashMap<String, ResolverMethodTable>,
1915    imported_from: &mut HashMap<String, String>,
1916    imported_from_kind: &mut HashMap<String, UnitKind>,
1917    errors: &mut ErrorSink,
1918) -> HashMap<String, ConsumedType> {
1919    // Names visible from `consumes` (read-only types from consumed contexts).
1920    // For each name we track:
1921    // - the type decl, with the consumed context's identity
1922    // - the visibility (opaque/transparent)
1923    // - the owning context's qualified name (for external-construction errors)
1924    let mut consumed_types: HashMap<String, ConsumedType> = HashMap::new();
1925
1926    // Now process `consumes` for contexts: add exported types into the
1927    // symbol table with visibility metadata so the checker can enforce
1928    // construction / inspection rules.
1929    for t in unit_info.get(name).into_iter().flat_map(|i| &i.consumes) {
1930        let used = &unit_info.get(t).expect("consumed unit present").table;
1931        let used_exports = &unit_info[t].exports;
1932        for (type_name, vis) in used_exports {
1933            let Some(decl) = used.types.get(type_name) else {
1934                continue;
1935            };
1936            if combined_types.contains_key(type_name) {
1937                // Name conflict between local/uses and consumed export.
1938                let consumes_span =
1939                    consumes_span_of(parsed, &unit_info[name].files, t).unwrap_or_default();
1940                errors.push_for(None,
1941                    CompileError::new(
1942                        "bynk.consumes.name_conflict",
1943                        consumes_span,
1944                        format!(
1945                            "context `{name}` consumes `{t}` which exports type `{type_name}`, but a type of the same name is already in scope",
1946                        ),
1947                    )
1948                    .with_note(
1949                        "rename one of the conflicting declarations or restructure the import",
1950                    ),
1951                );
1952                continue;
1953            }
1954            combined_types.insert(type_name.clone(), decl.clone());
1955            imported_from.insert(type_name.clone(), t.clone());
1956            imported_from_kind.insert(type_name.clone(), UnitKind::Context);
1957            consumed_types.insert(
1958                type_name.clone(),
1959                ConsumedType {
1960                    owning_context: t.clone(),
1961                    visibility: *vis,
1962                },
1963            );
1964            // Methods on transparently-exported types: they're emitted in
1965            // the owning context's output, but reading-side methods (like
1966            // user-declared instance methods) are callable from consumers.
1967            // For v0.4, we expose all instance methods on consumed types
1968            // so the checker can resolve method calls; the checker
1969            // separately enforces that constructors (.of/unsafe) aren't
1970            // callable externally.
1971            if let Some(mt) = used.methods.get(type_name) {
1972                let entry = combined_methods.entry(type_name.clone()).or_default();
1973                for (m, decl) in &mt.instance {
1974                    entry
1975                        .instance
1976                        .entry(m.clone())
1977                        .or_insert_with(|| decl.clone());
1978                }
1979                // We deliberately *don't* import static methods from
1980                // consumed contexts. Static methods can construct new
1981                // values, which is forbidden externally.
1982            }
1983        }
1984    }
1985
1986    consumed_types
1987}
1988
1989/// Phase 8a: compose one unit's symbol space — its local table plus a
1990/// one-level `uses` mixin (commons identity preserved). Returns the combined
1991/// type/fn/method tables and the `imported_from` provenance maps; the mixin
1992/// loop has no `continue`s, so it lifts out whole.
1993#[allow(clippy::type_complexity)]
1994fn compose_unit_symbols(
1995    name: &str,
1996    local_table: &UnitTable,
1997    unit_info: &HashMap<String, UnitInfo>,
1998) -> (
1999    HashMap<String, TypeDecl>,
2000    HashMap<String, FnDecl>,
2001    HashMap<String, ResolverMethodTable>,
2002    HashMap<String, String>,
2003    HashMap<String, UnitKind>,
2004) {
2005    // Compose: local + transitive (one level) uses. For commons, mixin
2006    // preserves type identity; for contexts, mixin produces per-context
2007    // nominal types. The resolver doesn't distinguish (the rebranding is
2008    // observable in emission); the symbol table union is the same.
2009    let mut combined_types = local_table.types.clone();
2010    let mut combined_fns = local_table.fns.clone();
2011    let mut combined_methods = local_table.methods.clone();
2012    let mut imported_from: HashMap<String, String> = HashMap::new();
2013    let mut imported_from_kind: HashMap<String, UnitKind> = HashMap::new();
2014
2015    for t in unit_info.get(name).into_iter().flat_map(|i| &i.uses) {
2016        let used = &unit_info.get(t).expect("used unit present").table;
2017        for (type_name, decl) in &used.types {
2018            if !combined_types.contains_key(type_name) {
2019                combined_types.insert(type_name.clone(), decl.clone());
2020                imported_from.insert(type_name.clone(), t.clone());
2021                imported_from_kind.insert(type_name.clone(), UnitKind::Commons);
2022            }
2023        }
2024        for (fn_name, decl) in &used.fns {
2025            if !combined_fns.contains_key(fn_name) {
2026                combined_fns.insert(fn_name.clone(), decl.clone());
2027                imported_from.insert(fn_name.clone(), t.clone());
2028                imported_from_kind.insert(fn_name.clone(), UnitKind::Commons);
2029            }
2030        }
2031        for (type_name, mt) in &used.methods {
2032            let entry = combined_methods.entry(type_name.clone()).or_default();
2033            for (m, decl) in &mt.instance {
2034                entry
2035                    .instance
2036                    .entry(m.clone())
2037                    .or_insert_with(|| decl.clone());
2038            }
2039            for (m, decl) in &mt.statics {
2040                entry
2041                    .statics
2042                    .entry(m.clone())
2043                    .or_insert_with(|| decl.clone());
2044            }
2045        }
2046    }
2047
2048    (
2049        combined_types,
2050        combined_fns,
2051        combined_methods,
2052        imported_from,
2053        imported_from_kind,
2054    )
2055}
2056
2057/// Phase 8e: build the emitter context for one checked source file and render
2058/// its TypeScript, pushing the result onto `compiled`. Reached only in build
2059/// mode (the caller's analyse-mode `continue` gates this off); the block is
2060/// straight-line with no `continue`s of its own.
2061#[allow(clippy::too_many_arguments)]
2062fn emit_unit(
2063    name: &str,
2064    kind: UnitKind,
2065    i: usize,
2066    pf: &ParsedFile,
2067    indices: &[usize],
2068    parsed: &[ParsedFile],
2069    unit_info: &HashMap<String, UnitInfo>,
2070    imported_from: &HashMap<String, String>,
2071    imported_from_kind: &HashMap<String, UnitKind>,
2072    owning_context_for_emit: &Option<String>,
2073    consumed_types: &HashMap<String, ConsumedType>,
2074    cross_context_for_file: &resolver::CrossContextInfo,
2075    typed: &checker::TypedCommons,
2076    target: BuildTarget,
2077    import_ext: ImportExt,
2078    compiled: &mut Vec<CompiledFile>,
2079) {
2080    // Build the emitter context.
2081    let info = &unit_info[name];
2082    let mut imported_decl_paths: HashMap<String, HashMap<String, PathBuf>> = HashMap::new();
2083    for t in &info.uses {
2084        if let Some(target_info) = unit_info.get(t) {
2085            let target_index = &target_info.file_index;
2086            let mut paths: HashMap<String, PathBuf> = HashMap::new();
2087            for (n, p) in &target_index.types {
2088                paths.insert(n.clone(), p.clone());
2089            }
2090            for (n, p) in &target_index.fns {
2091                paths.insert(n.clone(), p.clone());
2092            }
2093            imported_decl_paths.insert(t.clone(), paths);
2094        }
2095    }
2096    for t in &info.consumes {
2097        if let Some(target_info) = unit_info.get(t) {
2098            let target_index = &target_info.file_index;
2099            let mut paths: HashMap<String, PathBuf> = HashMap::new();
2100            // Only expose exported names — the emitter needs to know
2101            // which file declares them so it can render the import.
2102            let exports_for_target = &target_info.exports;
2103            for n in exports_for_target.keys() {
2104                if let Some(p) = target_index.types.get(n) {
2105                    paths.insert(n.clone(), p.clone());
2106                }
2107            }
2108            imported_decl_paths.insert(t.clone(), paths);
2109        }
2110    }
2111
2112    let exports_local = info.exports.clone();
2113    let exports_for_consumed = info
2114        .consumes
2115        .iter()
2116        .map(|t| {
2117            (
2118                t.clone(),
2119                unit_info
2120                    .get(t)
2121                    .map(|i| i.exports.clone())
2122                    .unwrap_or_default(),
2123            )
2124        })
2125        .collect();
2126    let cross_context_info = cross_context_for_file.clone();
2127
2128    // v0.8: in workers mode, a context's *output* lands under
2129    // workers/<dashes>/handlers.ts. Use that path as the synthetic
2130    // source_path so the emitter's depth/relative-path logic and
2131    // imported_decl_paths produce correct relative imports.
2132    let workers_mode = matches!(target, BuildTarget::Workers);
2133    let emit_source_path = if workers_mode && kind == UnitKind::Context {
2134        worker_handlers_source_path(name)
2135    } else {
2136        pf.source_path.clone()
2137    };
2138    let emit_local_files = if workers_mode && kind == UnitKind::Context {
2139        // Each context becomes one Worker; the body collapses into
2140        // one handlers.ts so there are no siblings to import from.
2141        Vec::new()
2142    } else {
2143        indices
2144            .iter()
2145            .filter_map(|&j| {
2146                if j == i {
2147                    None
2148                } else {
2149                    Some(parsed[j].source_path.clone())
2150                }
2151            })
2152            .collect()
2153    };
2154
2155    // In workers mode, rewrite imported_decl_paths for consumed
2156    // contexts to point at the consumed Worker's handlers.ts.
2157    let mut imported_decl_paths_emit = imported_decl_paths.clone();
2158    if workers_mode {
2159        for (unit, decls) in imported_decl_paths.iter() {
2160            let target_kind = unit_info.get(unit).map(|i| i.kind);
2161            if target_kind == Some(UnitKind::Context) {
2162                let handlers_path = worker_handlers_source_path(unit);
2163                let mut rewritten = HashMap::new();
2164                for n in decls.keys() {
2165                    rewritten.insert(n.clone(), handlers_path.clone());
2166                }
2167                imported_decl_paths_emit.insert(unit.clone(), rewritten);
2168            }
2169        }
2170    }
2171
2172    // v0.8: pre-compute boundary type owners so the emitter can
2173    // generate serialise/deserialise helper imports correctly. Only
2174    // relevant in workers mode for contexts.
2175    let boundary_type_owners = if workers_mode && kind == UnitKind::Context {
2176        compute_boundary_type_owners(name, unit_info, parsed)
2177    } else {
2178        HashMap::new()
2179    };
2180
2181    let emit_ctx = EmitProjectCtx {
2182        source_path: emit_source_path,
2183        commons_name: name.to_string(),
2184        local_files: emit_local_files,
2185        file_decl_index: info.file_index.clone(),
2186        imported_from: imported_from.clone(),
2187        imported_from_kind: imported_from_kind.clone(),
2188        imported_decl_paths: imported_decl_paths_emit,
2189        commons_dir: commons_dir_for(name),
2190        unit_kind: kind,
2191        owning_context: owning_context_for_emit.clone(),
2192        exports_local,
2193        exports_for_consumed,
2194        consumed_types: consumed_types.clone(),
2195        cross_context: cross_context_info,
2196        is_consumed_by_others: unit_info
2197            .values()
2198            .any(|i| i.consumes.iter().any(|t| t == name)),
2199        target,
2200        boundary_type_owners,
2201        local_agents: info.table.agents.keys().cloned().collect(),
2202        // v0.47: the context's actors (merged across files), so the Bearer
2203        // verification seam resolves even when the actor and handler are in
2204        // different files of the same context.
2205        actors: info.table.actors.clone(),
2206        consumed_adapters: info
2207            .consumes
2208            .iter()
2209            .filter(|t| unit_info.get(*t).map(|i| i.kind) == Some(UnitKind::Adapter))
2210            .cloned()
2211            .collect(),
2212        import_ext,
2213    };
2214    // v0.72: the map's `source` is the absolute path the compiler read the file
2215    // from, so an editor breakpoint set on the real `.bynk` resolves to the same
2216    // path the debugger loads (project-relative would resolve against the output
2217    // `.ts`'s directory — the wrong place). Synthetic units fall back to relative.
2218    let source_name = pf.map_source_name();
2219    let (ts, source_map) = emitter::emit_project(typed, &emit_ctx, &pf.source, &source_name);
2220    // Slice 3: the handler-label sidecar for this unit (ADR 0105) — names stack
2221    // frames by their Bynk operation. `None` for units with no handlers.
2222    let debug_metadata = emitter::collect_handler_labels(typed);
2223    let output_path = if workers_mode && kind == UnitKind::Context {
2224        worker_handlers_output_path(name)
2225    } else {
2226        ts_output_path(&pf.source_path)
2227    };
2228    compiled.push(CompiledFile {
2229        source_path: pf.source_path.clone(),
2230        output_path,
2231        typescript: ts,
2232        source_map,
2233        debug_metadata,
2234    });
2235}
2236
2237/// Phase 8d/8e: resolve + check (and, in build mode, emit) every source file in
2238/// one production unit. The per-file `continue`s stay internal to this loop, so
2239/// a file that fails resolution/checking is skipped without abandoning the unit.
2240#[allow(clippy::too_many_arguments)]
2241#[allow(clippy::type_complexity)]
2242fn check_unit_files(
2243    name: &str,
2244    kind: UnitKind,
2245    indices: &[usize],
2246    parsed: &[ParsedFile],
2247    unit_info: &HashMap<String, UnitInfo>,
2248    combined_types: &HashMap<String, TypeDecl>,
2249    combined_fns: &HashMap<String, FnDecl>,
2250    combined_methods: &HashMap<String, ResolverMethodTable>,
2251    local_names: &HashSet<String>,
2252    local_methods_for_type: &HashMap<String, Vec<FnDecl>>,
2253    consumed_types: &HashMap<String, ConsumedType>,
2254    imported_from: &HashMap<String, String>,
2255    imported_from_kind: &HashMap<String, UnitKind>,
2256    owning_context_for_emit: &Option<String>,
2257    target: BuildTarget,
2258    import_ext: ImportExt,
2259    mode: Mode,
2260    errors: &mut ErrorSink,
2261    refs: &mut RefSink,
2262    hints: &mut HintSink,
2263    locals: &mut LocalsSink,
2264    exprs: &mut ExprTypeSink,
2265    requirements: &mut RequirementSink,
2266    compiled: &mut Vec<CompiledFile>,
2267) {
2268    // v0.29.4: `build_cross_context_info` (and its `combined_types_for` helper)
2269    // is a general map-based function — the test-emission path calls it with
2270    // *synthetic* harness maps, not `unit_info` — so it keeps its parallel-map
2271    // signature. `check_unit_files` only has `unit_info`, so materialise the
2272    // four views that one call needs, once per unit ahead of the file loop.
2273    let unit_tables: HashMap<String, UnitTable> = unit_info
2274        .iter()
2275        .map(|(n, i)| (n.clone(), i.table.clone()))
2276        .collect();
2277    let unit_uses: HashMap<String, Vec<String>> = unit_info
2278        .iter()
2279        .map(|(n, i)| (n.clone(), i.uses.clone()))
2280        .collect();
2281    let unit_consumes: HashMap<String, Vec<String>> = unit_info
2282        .iter()
2283        .map(|(n, i)| (n.clone(), i.consumes.clone()))
2284        .collect();
2285    let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = unit_info
2286        .iter()
2287        .map(|(n, i)| (n.clone(), i.aliases.clone()))
2288        .collect();
2289
2290    for &i in indices {
2291        let pf = &parsed[i];
2292
2293        let mut emit_items: Vec<CommonsItem> = Vec::new();
2294        let types_in_this_file: HashSet<String> = pf
2295            .items()
2296            .iter()
2297            .filter_map(|it| match it {
2298                CommonsItem::Type(t) => Some(t.name.name.clone()),
2299                _ => None,
2300            })
2301            .collect();
2302        for item in pf.items() {
2303            match item {
2304                CommonsItem::Type(t) => {
2305                    emit_items.push(CommonsItem::Type(t.clone()));
2306                }
2307                CommonsItem::Fn(f) => match &f.name {
2308                    FnName::Free(_) => emit_items.push(CommonsItem::Fn(f.clone())),
2309                    FnName::Method { type_name, .. } => {
2310                        if types_in_this_file.contains(&type_name.name) {
2311                            emit_items.push(CommonsItem::Fn(f.clone()));
2312                        }
2313                    }
2314                },
2315                CommonsItem::Capability(c) => {
2316                    emit_items.push(CommonsItem::Capability(c.clone()));
2317                }
2318                CommonsItem::Provider(p) => {
2319                    emit_items.push(CommonsItem::Provider(p.clone()));
2320                }
2321                CommonsItem::Service(s) => {
2322                    emit_items.push(CommonsItem::Service(s.clone()));
2323                }
2324                CommonsItem::Agent(a) => {
2325                    emit_items.push(CommonsItem::Agent(a.clone()));
2326                }
2327                CommonsItem::Actor(a) => {
2328                    // Actors emit no standalone TS, but are carried so the
2329                    // emitter can read their schemes for the verification seam.
2330                    emit_items.push(CommonsItem::Actor(a.clone()));
2331                }
2332            }
2333        }
2334        for type_name in &types_in_this_file {
2335            if let Some(methods) = local_methods_for_type.get(type_name) {
2336                for m in methods {
2337                    let already = emit_items.iter().any(|it| match it {
2338                        CommonsItem::Fn(existing) => match &existing.name {
2339                            FnName::Method {
2340                                type_name: t,
2341                                method_name: n,
2342                            } => match &m.name {
2343                                FnName::Method {
2344                                    type_name: t2,
2345                                    method_name: n2,
2346                                } => t.name == t2.name && n.name == n2.name,
2347                                _ => false,
2348                            },
2349                            _ => false,
2350                        },
2351                        _ => false,
2352                    });
2353                    if !already {
2354                        emit_items.push(CommonsItem::Fn(m.clone()));
2355                    }
2356                }
2357            }
2358        }
2359
2360        // Synthesize a "Commons-shaped" view of this file's items so we
2361        // can drive the existing resolver/checker without duplication.
2362        let synthetic_commons = pf.as_synthetic_commons(emit_items);
2363
2364        // Cross-context info (v0.6) for contexts: consumed contexts,
2365        // aliases, services, and types. Computed once below; reused
2366        // for the resolver, checker, and emitter. v0.18: adapters get it
2367        // too, so an external provider's `given` resolves against the
2368        // adapter's flattened consumed capabilities (spec §4.5).
2369        let cross_context_for_file = if kind == UnitKind::Context || kind == UnitKind::Adapter {
2370            let mut cci = build_cross_context_info(
2371                name,
2372                &unit_consumes,
2373                &unit_consumes_aliases,
2374                &unit_uses,
2375                &unit_tables,
2376            );
2377            cci.flattened_caps = unit_info[name].flattened.clone();
2378            cci
2379        } else {
2380            resolver::CrossContextInfo::default()
2381        };
2382
2383        let resolved = ResolvedCommons {
2384            commons: synthetic_commons,
2385            types: combined_types.clone(),
2386            fns: combined_fns.clone(),
2387            methods: combined_methods.clone(),
2388            local_type_names: local_names.clone(),
2389            cross_context: cross_context_for_file.clone(),
2390            agents: HashMap::new(),
2391            // ADR 0116 D6: provenance for the `bynk.list` deprecation lint.
2392            imported_from: imported_from.clone(),
2393        };
2394        refs.enter_file(&pf.source_path, name, pf.synthetic);
2395        // v0.27: synthetic and test/integration files record no hints —
2396        // neither surfaces in an editor (the `assemble_index` rule).
2397        hints.enter_file(
2398            &pf.source_path,
2399            pf.synthetic || matches!(pf.kind, UnitKind::Test | UnitKind::Integration),
2400        );
2401        // v0.31: locals serve completion/navigation in test files too — only
2402        // synthetic (toolchain-injected) files are muted.
2403        locals.enter_file(&pf.source_path, pf.synthetic);
2404        // v0.99: capability requirements follow the inlay-hint muting rule —
2405        // synthetic and test/integration files surface none in an editor.
2406        requirements.enter_file(
2407            &pf.source_path,
2408            pf.synthetic || matches!(pf.kind, UnitKind::Test | UnitKind::Integration),
2409        );
2410        if let Err(errs) = resolver::resolve_file_record(&resolved, refs) {
2411            errors.extend_for(Some(&pf.source_path), errs);
2412            continue;
2413        }
2414        let rc = checker::check_record(resolved, refs, hints, locals, requirements);
2415        let typed = match rc.result {
2416            Ok(t) => {
2417                // v0.89 (ADR 0117): a unit that checks clean may still carry
2418                // non-failing warnings — push them into the (severity-aware)
2419                // sink, where they are classified as warnings and never gate.
2420                if !t.warnings.is_empty() {
2421                    errors.extend_for(Some(&pf.source_path), t.warnings.clone());
2422                }
2423                t
2424            }
2425            Err(errs) => {
2426                errors.extend_for(Some(&pf.source_path), errs);
2427                // ADR 0094: in Analyse mode, surface the best-effort partial types
2428                // the checker computed so `.`-member completion / signature help
2429                // work on a buffer with an unrelated error. Build bails (no
2430                // emission) as before.
2431                if mode == Mode::Analyse {
2432                    record_analyse_types(
2433                        exprs,
2434                        &pf.source_path,
2435                        pf.synthetic,
2436                        &rc.partial_expr_types,
2437                    );
2438                }
2439                continue;
2440            }
2441        };
2442
2443        // Run the context-specific checks: forbidden construction,
2444        // private-type references.
2445        if kind == UnitKind::Context {
2446            let context_check_errs = check_context_constraints(&typed, consumed_types, local_names);
2447            if !context_check_errs.is_empty() {
2448                errors.extend_for(Some(&pf.source_path), context_check_errs);
2449                if mode == Mode::Analyse {
2450                    record_analyse_types(exprs, &pf.source_path, pf.synthetic, &typed.expr_types);
2451                }
2452                continue;
2453            }
2454        }
2455
2456        // v0.5: check capability/provider/service/agent declarations.
2457        // v0.18: adapters run these too — an external provider's `given`
2458        // resolves through the same path as a bodied provider's (the
2459        // service/agent checks are vacuous for adapters, which have none).
2460        let mut typed = typed;
2461        let unit_table_owned = unit_info.get(name).map(|i| i.table.clone());
2462        if (kind == UnitKind::Context || kind == UnitKind::Adapter)
2463            && let Some(table) = unit_table_owned.as_ref()
2464        {
2465            let decl_errs = check_context_declarations(
2466                &mut typed,
2467                table,
2468                &cross_context_for_file,
2469                refs,
2470                hints,
2471                locals,
2472                requirements,
2473            );
2474            if !decl_errs.is_empty() {
2475                // ADR 0117: a warning-severity declaration diagnostic (e.g. the
2476                // `@indexed` hygiene hints) must not block emission — only an
2477                // error does. Partition first, then gate on error severity alone.
2478                let blocks_emission = decl_errs.iter().any(|e| {
2479                    matches!(
2480                        bynk_syntax::Severity::for_error(e),
2481                        bynk_syntax::Severity::Error
2482                    )
2483                });
2484                errors.extend_for(Some(&pf.source_path), decl_errs);
2485                if blocks_emission {
2486                    // ADR 0094: handler bodies are typed here — surface their
2487                    // best-effort types in Analyse mode even when a declaration check
2488                    // (e.g. a service/agent wiring error) fails for the file.
2489                    if mode == Mode::Analyse {
2490                        record_analyse_types(
2491                            exprs,
2492                            &pf.source_path,
2493                            pf.synthetic,
2494                            &typed.expr_types,
2495                        );
2496                    }
2497                    continue;
2498                }
2499                // Warnings only: the declarations are valid — fall through to emit.
2500            }
2501        }
2502
2503        // Analyse mode stops at checked: emission is build-only. Capture the
2504        // file's expression types on the way out (Ok path only — this point is
2505        // past every per-file error `continue`), for `.`-member completion.
2506        if mode == Mode::Analyse {
2507            record_analyse_types(exprs, &pf.source_path, pf.synthetic, &typed.expr_types);
2508            continue;
2509        }
2510        emit_unit(
2511            name,
2512            kind,
2513            i,
2514            pf,
2515            indices,
2516            parsed,
2517            unit_info,
2518            imported_from,
2519            imported_from_kind,
2520            owning_context_for_emit,
2521            consumed_types,
2522            &cross_context_for_file,
2523            &typed,
2524            target,
2525            import_ext,
2526            compiled,
2527        );
2528    }
2529}
2530
2531/// The outcome of the shared check pipeline (regions 1+2's shared work),
2532/// before either entry point applies its own divergent exit. The two typed
2533/// entry points (`compile_project`, `analyse_project`) project this into a
2534/// `Result<ProjectOutput, ProjectFailure>` or a `ProjectAnalysis`.
2535#[allow(clippy::large_enum_variant)]
2536enum RunChecks {
2537    /// Discovery/parse failed, or (build mode) the structural gate bailed:
2538    /// only diagnostics, no checked program. Index is not assembled here.
2539    Bailed {
2540        errors: ErrorSink,
2541        snapshots: Vec<(PathBuf, String)>,
2542        hints: HintSink,
2543        locals: LocalsSink,
2544        exprs: ExprTypeSink,
2545        requirements: RequirementSink,
2546    },
2547    /// All phases ran (per-unit checks + tests + platform-lock done).
2548    Checked {
2549        errors: ErrorSink,
2550        snapshots: Vec<(PathBuf, String)>,
2551        refs: RefSink,
2552        hints: HintSink,
2553        locals: LocalsSink,
2554        exprs: ExprTypeSink,
2555        requirements: RequirementSink,
2556        parsed: Vec<ParsedFile>,
2557        compiled: Vec<CompiledFile>,
2558        runnable_tests: Vec<RunnableTest>,
2559        integration_outputs: Vec<CompiledFile>,
2560        integration_runnables: Vec<RunnableTest>,
2561        groups: HashMap<String, Vec<usize>>,
2562        kinds: HashMap<String, UnitKind>,
2563        unit_uses: HashMap<String, Vec<String>>,
2564        unit_consumes: HashMap<String, Vec<String>>,
2565        unit_consumes_aliases: HashMap<String, HashMap<String, String>>,
2566        unit_tables: HashMap<String, UnitTable>,
2567        unit_flattened: HashMap<String, HashMap<String, String>>,
2568        adapter_bindings: HashMap<String, AdapterBinding>,
2569        npm_deps: std::collections::BTreeMap<String, String>,
2570        target: BuildTarget,
2571    },
2572}
2573
2574#[allow(clippy::too_many_arguments)]
2575fn run_checks(
2576    src_root: &Path,
2577    tests_root: &Path,
2578    tests_prefix: &Path,
2579    target: BuildTarget,
2580    platform: Platform,
2581    import_ext: ImportExt,
2582    mode: Mode,
2583    overlay: &HashMap<PathBuf, String>,
2584    // v0.113: absolute subtrees to skip during discovery (author `exclude` plus
2585    // the tool's `out`/`node_modules` caches). Empty for in-memory builds.
2586    excludes: &[PathBuf],
2587    // v0.108 (in-browser track, slice 3): when `Some`, the source files are
2588    // supplied directly — `(src_files, tests_files)` — and filesystem discovery
2589    // is skipped. The wasm/REPL entry feeds an in-memory single-module project
2590    // this way (the source itself rides in `overlay`); `None` keeps the on-disk
2591    // discovery walk for the CLI and the LSP.
2592    discovered: Option<(Vec<PathBuf>, Vec<PathBuf>)>,
2593) -> RunChecks {
2594    let mut errors = ErrorSink::new();
2595    // v0.25 (ADR 0053): binding edges, recorded at the resolution sites and
2596    // assembled into the project index at the analyse exit.
2597    let mut refs = RefSink::new();
2598    // v0.27 (ADR 0056): inferred-type inlay hints, recorded at the checker's
2599    // binding sites. A sink (not part of the checker's Ok payload) so hints
2600    // survive the per-file error-`continue`s.
2601    let mut hints = HintSink::new();
2602    let mut locals = LocalsSink::new();
2603    // v0.99: the capability-requirement ledger — recorded at the checker's
2604    // capability-consuming sites, drained at the analyse exit for the LSP.
2605    let mut requirements = RequirementSink::new();
2606    // v0.30.2 (ADR 0063): per-file expression types, captured on the Ok path so
2607    // `.`-member completion can type a receiver. Carried like `hints`.
2608    let mut exprs = ExprTypeSink::new();
2609    let mut snapshots: Vec<(PathBuf, String)> = Vec::new();
2610    let split_mode = src_root != tests_root;
2611
2612    // -- 1. Discovery (skipped when sources are supplied in memory). --
2613    let (src_files, tests_files) = match discovered {
2614        Some(files) => files,
2615        None => match phase_discovery(src_root, tests_root, split_mode, excludes, &mut errors) {
2616            Ok(files) => files,
2617            Err(()) => {
2618                return RunChecks::Bailed {
2619                    errors,
2620                    snapshots,
2621                    hints,
2622                    locals,
2623                    exprs,
2624                    requirements,
2625                };
2626            }
2627        },
2628    };
2629
2630    // -- 2. Parse every file. --
2631    let (parsed, consumes_bynk, consumes_cloudflare) = match phase_parse(
2632        src_root,
2633        tests_root,
2634        split_mode,
2635        &src_files,
2636        &tests_files,
2637        overlay,
2638        &mut errors,
2639        &mut snapshots,
2640    ) {
2641        Ok(out) => out,
2642        Err(()) => {
2643            return RunChecks::Bailed {
2644                errors,
2645                snapshots,
2646                hints,
2647                locals,
2648                exprs,
2649                requirements,
2650            };
2651        }
2652    };
2653
2654    // -- 3. Group by (name, kind) and validate per-directory consistency. --
2655    let (groups, kinds, test_groups, integration_groups, adapter_bindings, npm_deps) = phase_group(
2656        &parsed,
2657        src_root,
2658        platform,
2659        consumes_bynk,
2660        consumes_cloudflare,
2661        &mut errors,
2662    );
2663
2664    // -- 4. Build per-unit combined symbol tables. --
2665    let unit_tables = phase_symbol_tables(&groups, &kinds, &parsed, &mut errors);
2666
2667    // -- 5. Resolve `uses` clauses (target must exist + be a commons). --
2668    let unit_uses = phase_resolve_uses(&groups, &kinds, &parsed, &unit_tables, &mut errors);
2669
2670    // -- 5b. Resolve `consumes` clauses (target must exist + be a context). --
2671    let (unit_consumes, unit_flattened) = phase_resolve_consumes(
2672        &groups,
2673        &kinds,
2674        &parsed,
2675        &unit_tables,
2676        &mut errors,
2677        &mut refs,
2678    );
2679
2680    // -- 5b'. Collect `consumes` aliases (v0.6 §3.1). Each consuming context
2681    //         has an alias map: alias → consumed-context qualified name.
2682    //         Detect alias-alias conflicts here; alias-vs-local-decl conflicts
2683    //         are checked once the local symbol tables are built (step 6+).
2684    let unit_consumes_aliases =
2685        phase_consumes_aliases(&groups, &kinds, &parsed, &unit_tables, &mut errors);
2686
2687    // -- 5c. Detect `consumes` cycles. --
2688    let mut cycle_errors: Vec<CompileError> = Vec::new();
2689    detect_consumes_cycles(&unit_consumes, &mut cycle_errors);
2690    errors.extend_for(None, cycle_errors);
2691
2692    // -- 6. Name-conflict detection for uses imports (commons-only check). --
2693    phase_uses_name_conflicts(&unit_uses, &unit_tables, &parsed, &groups, &mut errors);
2694
2695    // -- 6b. Validate exports clauses (each name is a locally-declared type;
2696    //         no duplicates within or across opaque/transparent). --
2697    let exports_visibility = phase_validate_type_exports(
2698        &groups,
2699        &kinds,
2700        &parsed,
2701        &unit_tables,
2702        &mut errors,
2703        &mut refs,
2704    );
2705
2706    // -- 6b'. Validate `exports capability { … }` clauses (v0.15 §4.1): each
2707    //          name must be a capability the context declares *and* provides. --
2708    phase_validate_capability_exports(
2709        &groups,
2710        &kinds,
2711        &parsed,
2712        &unit_tables,
2713        &mut errors,
2714        &mut refs,
2715    );
2716
2717    // -- 6c. Validate that providers match their capabilities exactly. --
2718    phase_validate_providers(&unit_tables, &mut errors);
2719
2720    if !errors.is_empty() && mode == Mode::Build {
2721        return RunChecks::Bailed {
2722            errors,
2723            snapshots,
2724            hints,
2725            locals,
2726            exprs,
2727            requirements,
2728        };
2729    }
2730
2731    // -- 7. Build per-unit file index (which file declares which name). --
2732    let unit_file_index = phase_file_index(&groups, &parsed);
2733
2734    // -- 7b (v0.29.4). Assemble the nine parallel per-unit maps into one record
2735    //          per unit. Driven by the `groups` keyset (the authority), so every
2736    //          group yields exactly one `UnitInfo` with all facets present. The
2737    //          producer maps are cloned, not moved, because the back half of the
2738    //          pipeline (tests, integration tests, platform-lock, composition
2739    //          root, the workers branch) still reads the originals.
2740    let unit_info = assemble_unit_info(
2741        &groups,
2742        &kinds,
2743        &unit_tables,
2744        &unit_uses,
2745        &unit_consumes,
2746        &unit_flattened,
2747        &unit_consumes_aliases,
2748        &exports_visibility,
2749        &unit_file_index,
2750    );
2751
2752    // -- 8. For each unit, build the combined symbol space and run
2753    //       resolve+check per source file. --
2754    let mut compiled: Vec<CompiledFile> = Vec::new();
2755
2756    for (name, info) in &unit_info {
2757        let kind = info.kind;
2758        let indices = info.files.as_slice();
2759        let local_table = &info.table;
2760        // v0.24: skip resolve/check only when THIS group's composition
2761        // failed. In build mode the sink is empty here (the structural gate
2762        // bailed), so the delta equals the old global is_empty check; in
2763        // analyse mode one broken unit no longer suppresses every other
2764        // unit's semantic diagnostics.
2765        let group_error_baseline = errors.len();
2766
2767        let (
2768            mut combined_types,
2769            combined_fns,
2770            mut combined_methods,
2771            mut imported_from,
2772            mut imported_from_kind,
2773        ) = compose_unit_symbols(name, local_table, &unit_info);
2774        let consumed_types = merge_consumed_exports(
2775            name,
2776            &parsed,
2777            &unit_info,
2778            &mut combined_types,
2779            &mut combined_methods,
2780            &mut imported_from,
2781            &mut imported_from_kind,
2782            &mut errors,
2783        );
2784
2785        if errors.len() > group_error_baseline {
2786            continue;
2787        }
2788
2789        let local_names: HashSet<String> = local_table.types.keys().cloned().collect();
2790
2791        let local_methods_for_type = collect_unit_methods(indices, &parsed);
2792
2793        // Per-context view information for the emitter and checker.
2794        let owning_context_for_emit = if kind == UnitKind::Context {
2795            Some(name.clone())
2796        } else {
2797            None
2798        };
2799
2800        check_unit_files(
2801            name,
2802            kind,
2803            indices,
2804            &parsed,
2805            &unit_info,
2806            &combined_types,
2807            &combined_fns,
2808            &combined_methods,
2809            &local_names,
2810            &local_methods_for_type,
2811            &consumed_types,
2812            &imported_from,
2813            &imported_from_kind,
2814            &owning_context_for_emit,
2815            target,
2816            import_ext,
2817            mode,
2818            &mut errors,
2819            &mut refs,
2820            &mut hints,
2821            &mut locals,
2822            &mut exprs,
2823            &mut requirements,
2824            &mut compiled,
2825        );
2826    }
2827
2828    // v0.7: process test declarations. Each `test commerce.X` group resolves
2829    // its target, validates mocks against the target's capability/consumed-
2830    // context shapes, type-checks bodies with the target's privileged view,
2831    // and emits a per-target TypeScript test module under `tests/`.
2832    let mut test_errors: Vec<CompileError> = Vec::new();
2833    let (test_outputs, runnable_tests) = process_tests(
2834        &test_groups,
2835        &parsed,
2836        &kinds,
2837        &unit_tables,
2838        &exports_visibility,
2839        &unit_consumes,
2840        &unit_consumes_aliases,
2841        &unit_uses,
2842        tests_prefix,
2843        import_ext,
2844        &mut test_errors,
2845        &mut refs,
2846    );
2847    errors.extend_for(None, test_errors);
2848
2849    compiled.extend(test_outputs);
2850
2851    // v0.16: process integration tests. Each `test integration "name"` suite
2852    // validates its `wires` participants, type-checks each case body as a
2853    // cross-context call from a synthetic harness root that consumes every
2854    // participant, and emits a TypeScript module that stands the participants
2855    // up as in-process Workers and exercises the flow across the real wire.
2856    let mut integration_errors: Vec<CompileError> = Vec::new();
2857    let (integration_outputs, integration_runnables) = process_integration_tests(
2858        &integration_groups,
2859        &parsed,
2860        &kinds,
2861        &unit_tables,
2862        &unit_consumes,
2863        &unit_consumes_aliases,
2864        &unit_uses,
2865        tests_prefix,
2866        &mut integration_errors,
2867        &mut refs,
2868    );
2869    errors.extend_for(None, integration_errors);
2870
2871    // v0.19 (decisions 0017/0024): platform-lock enforcement. A deployment
2872    // unit whose in-process closure reaches a platform-native capability is
2873    // locked to that platform; the selected `--platform` must match. Run only
2874    // on otherwise-clean programs: the closure walk recurses the provider
2875    // graph, whose acyclicity the earlier checks establish.
2876    if errors.is_empty() {
2877        let mut lock_errors: Vec<CompileError> = Vec::new();
2878        check_platform_lock(
2879            target,
2880            platform,
2881            &parsed,
2882            &groups,
2883            &kinds,
2884            &unit_tables,
2885            &unit_consumes,
2886            &unit_consumes_aliases,
2887            &unit_flattened,
2888            &mut lock_errors,
2889        );
2890        errors.extend_for(None, lock_errors);
2891    }
2892
2893    // v0.110 (ADR 0142 D8): under `--target workers`, a bare `Bytes` in a wire
2894    // signature crosses the erased cross-context boundary, which does not
2895    // base64-encode it. Diagnose it rather than mis-encode; the typed paths
2896    // (`bundle` calls, `store`/record fields) round-trip a `Bytes` fine.
2897    if target == BuildTarget::Workers {
2898        let mut bytes_boundary_errors: Vec<CompileError> = Vec::new();
2899        check_bytes_workers_boundaries(&parsed, &mut bytes_boundary_errors);
2900        errors.extend_for(None, bytes_boundary_errors);
2901    }
2902
2903    RunChecks::Checked {
2904        errors,
2905        snapshots,
2906        refs,
2907        hints,
2908        locals,
2909        exprs,
2910        requirements,
2911        parsed,
2912        compiled,
2913        runnable_tests,
2914        integration_outputs,
2915        integration_runnables,
2916        groups,
2917        kinds,
2918        unit_uses,
2919        unit_consumes,
2920        unit_consumes_aliases,
2921        unit_tables,
2922        unit_flattened,
2923        adapter_bindings,
2924        npm_deps,
2925        target,
2926    }
2927}
2928
2929/// Build-success tail (region 3): emit the composition/worker/runtime files
2930/// and assemble the final `ProjectOutput`. Reached only on build mode with a
2931/// clean error sink. Moved verbatim from the old pipeline; only the locals it
2932/// reads are now bound from the `Checked` variant.
2933#[allow(clippy::too_many_arguments)]
2934fn build_output(
2935    mut compiled: Vec<CompiledFile>,
2936    mut runnable_tests: Vec<RunnableTest>,
2937    integration_outputs: Vec<CompiledFile>,
2938    integration_runnables: Vec<RunnableTest>,
2939    groups: HashMap<String, Vec<usize>>,
2940    kinds: HashMap<String, UnitKind>,
2941    unit_consumes: HashMap<String, Vec<String>>,
2942    unit_consumes_aliases: HashMap<String, HashMap<String, String>>,
2943    unit_tables: HashMap<String, UnitTable>,
2944    unit_flattened: HashMap<String, HashMap<String, String>>,
2945    adapter_bindings: HashMap<String, AdapterBinding>,
2946    npm_deps: std::collections::BTreeMap<String, String>,
2947    target: BuildTarget,
2948    import_ext: ImportExt,
2949) -> ProjectOutput {
2950    compiled.extend(integration_outputs);
2951    runnable_tests.extend(integration_runnables);
2952
2953    // v0.67: the discovery manifest — built from the combined runnable set before
2954    // anything consumes it, so `--no-run --format json` lists suites/cases without
2955    // running. Ordered by the runner's sort key to match a run's suite order.
2956    let discovered = discovery_manifest(&runnable_tests);
2957
2958    // v0.16: emit the combined top-level test runner once both passes are done,
2959    // so `tests/main.ts` aggregates unit and integration suites together.
2960    if !runnable_tests.is_empty() {
2961        let main_ts = emit_test_main(&runnable_tests, import_ext);
2962        compiled.push(CompiledFile {
2963            source_path: PathBuf::from("tests/main.test.bynk"),
2964            output_path: PathBuf::from("tests/main.ts"),
2965            typescript: main_ts,
2966            source_map: None,
2967            debug_metadata: None,
2968        });
2969    }
2970
2971    // v0.19 (decision 0025): does any context's in-process closure reach a
2972    // platform-native unit? Drives env threading (bundle) and the per-Worker
2973    // Env/`wrangler.toml` resource derivation (workers).
2974    let context_native: HashMap<String, std::collections::BTreeMap<Platform, String>> = kinds
2975        .iter()
2976        .filter(|(_, k)| **k == UnitKind::Context)
2977        .filter_map(|(name, _)| {
2978            let table = unit_tables.get(name)?;
2979            let native = native_platforms_of_context(
2980                name,
2981                table,
2982                &unit_tables,
2983                &unit_consumes,
2984                &unit_consumes_aliases,
2985                &unit_flattened,
2986            );
2987            (!native.is_empty()).then(|| (name.clone(), native))
2988        })
2989        .collect();
2990
2991    match target {
2992        BuildTarget::Bundle => {
2993            // v0.6 §6.3: emit a composition root when the project has at
2994            // least one context that consumes another context's service
2995            // surface. The compose file imports each context, instantiates
2996            // its providers, assembles its deps (capabilities + cross-
2997            // context surfaces), and exports the top-level service surface.
2998            if let Some(compose_ts) = emit_composition_root(
2999                &groups,
3000                &kinds,
3001                &unit_consumes,
3002                &unit_consumes_aliases,
3003                &unit_tables,
3004                &adapter_bindings,
3005                &unit_flattened,
3006                // D1: thread `env` through composeApp only when a native
3007                // resource is consumed, so native-free programs are
3008                // byte-identical to v0.18 output.
3009                !context_native.is_empty(),
3010            ) {
3011                compiled.push(CompiledFile {
3012                    source_path: PathBuf::from("compose.bynk"),
3013                    output_path: PathBuf::from("compose.ts"),
3014                    typescript: compose_ts,
3015                    source_map: None,
3016                    debug_metadata: None,
3017                });
3018            }
3019        }
3020        BuildTarget::Workers => {
3021            // v0.8 §2.3: per-Worker entry point, compose.ts, and wrangler
3022            // configuration. One Worker per context.
3023            for (ctx_name, kind) in &kinds {
3024                if *kind != UnitKind::Context {
3025                    continue;
3026                }
3027                let Some(table) = unit_tables.get(ctx_name) else {
3028                    continue;
3029                };
3030                let dashes = worker_dir_name(ctx_name);
3031                let consumes_targets = unit_consumes.get(ctx_name).cloned().unwrap_or_default();
3032                let aliases = unit_consumes_aliases
3033                    .get(ctx_name)
3034                    .cloned()
3035                    .unwrap_or_default();
3036                let entry_ts = emitter::emit_worker_entry(ctx_name, table);
3037                let binding_modules: HashMap<String, String> = adapter_bindings
3038                    .iter()
3039                    .map(|(n, b)| {
3040                        (
3041                            n.clone(),
3042                            emitter::ts_specifier(&b.output_path.with_extension("js")),
3043                        )
3044                    })
3045                    .collect();
3046                let flattened = unit_flattened.get(ctx_name).cloned().unwrap_or_default();
3047                // v0.19 (C1): this Worker needs the KV namespace binding when
3048                // its in-process closure reaches the cloudflare adapter.
3049                let needs_kv = context_native
3050                    .get(ctx_name)
3051                    .is_some_and(|n| n.values().any(|u| u == firstparty::CLOUDFLARE_UNIT));
3052                let compose_ts = emitter::emit_worker_compose(
3053                    ctx_name,
3054                    table,
3055                    &consumes_targets,
3056                    &aliases,
3057                    &unit_tables,
3058                    &binding_modules,
3059                    &flattened,
3060                    &unit_consumes,
3061                    &unit_consumes_aliases,
3062                    &unit_flattened,
3063                    needs_kv,
3064                );
3065                // Adapters are not Workers, so they get no Service Binding in
3066                // the consumer's wrangler config — drop them from the list.
3067                let service_consumes: Vec<String> = consumes_targets
3068                    .iter()
3069                    .filter(|t| !binding_modules.contains_key(*t))
3070                    .cloned()
3071                    .collect();
3072                let wrangler =
3073                    emitter::emit_wrangler_toml(ctx_name, table, &service_consumes, needs_kv);
3074                compiled.push(CompiledFile {
3075                    source_path: PathBuf::from(format!("workers/{dashes}/<index>")),
3076                    output_path: PathBuf::from(format!("workers/{dashes}/index.ts")),
3077                    typescript: entry_ts,
3078                    source_map: None,
3079                    debug_metadata: None,
3080                });
3081                compiled.push(CompiledFile {
3082                    source_path: PathBuf::from(format!("workers/{dashes}/<compose>")),
3083                    output_path: PathBuf::from(format!("workers/{dashes}/compose.ts")),
3084                    typescript: compose_ts,
3085                    source_map: None,
3086                    debug_metadata: None,
3087                });
3088                compiled.push(CompiledFile {
3089                    source_path: PathBuf::from(format!("workers/{dashes}/<wrangler>")),
3090                    output_path: PathBuf::from(format!("workers/{dashes}/wrangler.toml")),
3091                    typescript: wrangler,
3092                    source_map: None,
3093                    debug_metadata: None,
3094                });
3095            }
3096        }
3097    }
3098
3099    // v0.17: copy each adapter binding verbatim into the output, beside the
3100    // adapter's emitted interface module, so compose's import resolves and the
3101    // `tsc` gate checks the `implements` contract.
3102    let mut binding_names: Vec<&String> = adapter_bindings.keys().collect();
3103    binding_names.sort();
3104    for name in binding_names {
3105        let b = &adapter_bindings[name];
3106        compiled.push(CompiledFile {
3107            source_path: b.output_path.clone(),
3108            output_path: b.output_path.clone(),
3109            typescript: b.content.clone(),
3110            source_map: None,
3111            debug_metadata: None,
3112        });
3113    }
3114
3115    // v0.17: emit `package.json` only when an adapter declares npm deps, so
3116    // existing (adapter-free) projects are unchanged.
3117    if !npm_deps.is_empty() {
3118        compiled.push(CompiledFile {
3119            source_path: PathBuf::from("<package.json>"),
3120            output_path: PathBuf::from("package.json"),
3121            typescript: render_package_json(&npm_deps),
3122            source_map: None,
3123            debug_metadata: None,
3124        });
3125    }
3126
3127    // Runtime + tsconfig: emit once per project. The runtime sits at the
3128    // root of `out/` so every emitted file's `runtime.js` import resolves
3129    // relative to it. `tsconfig.json` is also at the root so `tsc -p out/
3130    // tsconfig.json` discovers every `.ts` file in the tree.
3131    compiled.push(CompiledFile {
3132        source_path: PathBuf::from("<runtime>"),
3133        output_path: PathBuf::from("runtime.ts"),
3134        typescript: emitter::emit_runtime_module(),
3135        source_map: None,
3136        debug_metadata: None,
3137    });
3138    compiled.push(CompiledFile {
3139        source_path: PathBuf::from("<tsconfig>"),
3140        output_path: PathBuf::from("tsconfig.json"),
3141        typescript: emitter::emit_tsconfig(),
3142        source_map: None,
3143        debug_metadata: None,
3144    });
3145
3146    compiled.sort_by(|a, b| a.source_path.cmp(&b.source_path));
3147    ProjectOutput {
3148        files: compiled,
3149        discovered,
3150        // Populated by `compile_project` from the run's warning sink (ADR 0117).
3151        warnings: Vec::new(),
3152    }
3153}
3154
3155/// Build a project-level composition root that wires every context's
3156/// providers and cross-context surfaces together. Returns `None` if the
3157/// project has no cross-context wiring to glue.
3158/// Resolve a `given` prefix (alias or qualified context name) to a consumed
3159/// context, using one context's `consumes`/alias tables (v0.15).
3160fn resolve_consume_prefix(
3161    prefix: &str,
3162    consumed: &[String],
3163    aliases: &HashMap<String, String>,
3164) -> Option<String> {
3165    if let Some(q) = aliases.get(prefix) {
3166        return Some(q.clone());
3167    }
3168    if consumed.iter().any(|c| c == prefix) {
3169        return Some(prefix.to_string());
3170    }
3171    None
3172}
3173
3174/// v0.15: the cross-context capabilities a context's **handlers** reference,
3175/// as `deps_key → consumed_context`. These become top-level deps fields.
3176fn handler_cross_caps(
3177    table: &UnitTable,
3178    consumed: &[String],
3179    aliases: &HashMap<String, String>,
3180    flattened: &HashMap<String, String>,
3181) -> std::collections::BTreeMap<String, String> {
3182    let mut out = std::collections::BTreeMap::new();
3183    let mut scan = |given: &[CapRef]| {
3184        for c in given {
3185            if let Some(p) = c.prefix() {
3186                if let Some(ctx) = resolve_consume_prefix(&p, consumed, aliases) {
3187                    out.entry(c.key().to_string()).or_insert(ctx);
3188                }
3189            } else if let Some(unit) = flattened.get(c.key()) {
3190                // v0.17: a bare flattened capability is provided by the unit it
3191                // was flattened from.
3192                out.entry(c.key().to_string())
3193                    .or_insert_with(|| unit.clone());
3194            }
3195        }
3196    };
3197    for s in table.services.values() {
3198        for h in &s.handlers {
3199            scan(&h.given);
3200        }
3201    }
3202    for a in table.agents.values() {
3203        for h in &a.handlers {
3204            scan(&h.given);
3205        }
3206    }
3207    out
3208}
3209
3210/// v0.19 (decision 0017): the native platforms a context's **in-process
3211/// closure** commits it to: every unit whose provider its compose would
3212/// instantiate — local providers' `given` recursion plus the capabilities its
3213/// handlers reference — mapped through [`firstparty::platform_of`]. Each
3214/// platform carries an exemplar unit for the diagnostic message. Service
3215/// `consumes` edges (RPC under `workers`) do not contribute — only the
3216/// provider-instantiation walk, which is in-process by construction.
3217#[allow(clippy::too_many_arguments)]
3218fn native_platforms_of_context(
3219    ctx: &str,
3220    table: &UnitTable,
3221    unit_tables: &HashMap<String, UnitTable>,
3222    unit_consumes: &HashMap<String, Vec<String>>,
3223    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3224    unit_flattened: &HashMap<String, HashMap<String, String>>,
3225) -> std::collections::BTreeMap<Platform, String> {
3226    let mut referenced: BTreeSet<String> = BTreeSet::new();
3227    for cap in table.providers.keys() {
3228        let _ = instantiate_provider_expr(
3229            ctx,
3230            cap,
3231            unit_tables,
3232            unit_consumes,
3233            unit_consumes_aliases,
3234            unit_flattened,
3235            false,
3236            None,
3237            &mut referenced,
3238        );
3239    }
3240    let consumed = unit_consumes.get(ctx).cloned().unwrap_or_default();
3241    let aliases = unit_consumes_aliases.get(ctx).cloned().unwrap_or_default();
3242    let flattened = unit_flattened.get(ctx).cloned().unwrap_or_default();
3243    for (key, cctx) in handler_cross_caps(table, &consumed, &aliases, &flattened) {
3244        let _ = instantiate_provider_expr(
3245            &cctx,
3246            &key,
3247            unit_tables,
3248            unit_consumes,
3249            unit_consumes_aliases,
3250            unit_flattened,
3251            false,
3252            None,
3253            &mut referenced,
3254        );
3255    }
3256    let mut out = std::collections::BTreeMap::new();
3257    for unit in referenced {
3258        if let Some(p) = bynk_check::firstparty::platform_of(&unit) {
3259            out.entry(p).or_insert(unit);
3260        }
3261    }
3262    out
3263}
3264
3265/// v0.15: build the TypeScript expression instantiating the provider of
3266/// capability `cap` declared in `provider_ctx`, recursively wiring its `given`
3267/// dependencies — local sibling providers and cross-context capability
3268/// providers alike. Stateless providers, so fresh instances per use are fine.
3269///
3270/// v0.18 (spec §4.5/§5.1): a *bare* `given` name resolves through the
3271/// provider's own unit's flattened-capability map (`Fetch` → `bynk`), falling
3272/// back to the unit itself; an *external* provider's deps are built the same
3273/// way and passed to the binding class constructor by name. Every unit whose
3274/// namespace the expression references is recorded in `referenced_units` so
3275/// the caller can emit the matching imports (the transitive given-closure).
3276///
3277/// `workers_ns` selects the namespace convention: a bodied provider's class
3278/// lives in `{ns}` under the bundle root but `handlers_{ns}` in a Worker
3279/// compose; external (binding) classes are `{ns}__binding` in both. When
3280/// `env_ident` is set (workers), env-taking first-party providers receive it
3281/// as a constructor argument.
3282#[allow(clippy::too_many_arguments)]
3283pub(crate) fn instantiate_provider_expr(
3284    provider_ctx: &str,
3285    cap: &str,
3286    unit_tables: &HashMap<String, UnitTable>,
3287    unit_consumes: &HashMap<String, Vec<String>>,
3288    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3289    unit_flattened: &HashMap<String, HashMap<String, String>>,
3290    workers_ns: bool,
3291    env_ident: Option<&str>,
3292    referenced_units: &mut BTreeSet<String>,
3293) -> String {
3294    let ns = provider_ctx.replace('.', "_");
3295    let bodied_ns = if workers_ns {
3296        format!("handlers_{ns}")
3297    } else {
3298        ns.clone()
3299    };
3300    referenced_units.insert(provider_ctx.to_string());
3301    let Some(provider) = unit_tables
3302        .get(provider_ctx)
3303        .and_then(|t| t.providers.get(cap))
3304    else {
3305        return format!("new {bodied_ns}.{cap}()");
3306    };
3307    // Build the by-name deps object from the provider's `given`, if any.
3308    let deps_obj = if provider.given.is_empty() {
3309        None
3310    } else {
3311        let consumed = unit_consumes.get(provider_ctx).cloned().unwrap_or_default();
3312        let aliases = unit_consumes_aliases
3313            .get(provider_ctx)
3314            .cloned()
3315            .unwrap_or_default();
3316        let flattened = unit_flattened
3317            .get(provider_ctx)
3318            .cloned()
3319            .unwrap_or_default();
3320        let deps: Vec<String> = provider
3321            .given
3322            .iter()
3323            .map(|g| {
3324                let target_ctx = match g.prefix() {
3325                    Some(p) => resolve_consume_prefix(&p, &consumed, &aliases)
3326                        .unwrap_or_else(|| provider_ctx.to_string()),
3327                    None => flattened
3328                        .get(g.key())
3329                        .cloned()
3330                        .unwrap_or_else(|| provider_ctx.to_string()),
3331                };
3332                let expr = instantiate_provider_expr(
3333                    &target_ctx,
3334                    g.key(),
3335                    unit_tables,
3336                    unit_consumes,
3337                    unit_consumes_aliases,
3338                    unit_flattened,
3339                    workers_ns,
3340                    env_ident,
3341                    referenced_units,
3342                );
3343                format!("{}: {}", g.key(), expr)
3344            })
3345            .collect();
3346        Some(format!("{{ {} }}", deps.join(", ")))
3347    };
3348    let mut args: Vec<String> = deps_obj.into_iter().collect();
3349    // v0.18/v0.19: env-taking first-party providers (the bynk surface's
3350    // SecretsProvider; bynk.cloudflare's WorkersKv) receive the Worker `env`
3351    // explicitly — decisions 0021/0025. Keyed by (unit, class).
3352    if provider.external
3353        && bynk_check::firstparty::provider_takes_env(provider_ctx, &provider.provider_name.name)
3354        && let Some(env) = env_ident
3355    {
3356        args.push(env.to_string());
3357    }
3358    let class = &provider.provider_name.name;
3359    let args = args.join(", ");
3360    // v0.17: an external (adapter) provider's class lives in the binding module,
3361    // not the adapter's interface module — instantiate it from the binding
3362    // namespace (`<adapter>__binding`, imported by the composition root).
3363    if provider.external {
3364        format!("new {ns}__binding.{class}({args})")
3365    } else {
3366        format!("new {bodied_ns}.{class}({args})")
3367    }
3368}
3369
3370#[allow(clippy::too_many_arguments)]
3371fn emit_composition_root(
3372    groups: &HashMap<String, Vec<usize>>,
3373    kinds: &HashMap<String, UnitKind>,
3374    unit_consumes: &HashMap<String, Vec<String>>,
3375    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3376    unit_tables: &HashMap<String, UnitTable>,
3377    adapter_bindings: &HashMap<String, AdapterBinding>,
3378    unit_flattened: &HashMap<String, HashMap<String, String>>,
3379    // v0.19 (decision 0025, D1): when the program's closure reaches a
3380    // platform-native unit, composeApp takes an optional `env` and threads it
3381    // to env-taking first-party providers. A bundle on Cloudflare is a single
3382    // Worker with `env` at its entry; native-free programs emit the v0.18
3383    // no-parameter signature unchanged.
3384    thread_env: bool,
3385) -> Option<String> {
3386    // Identify contexts that consume something whose surface has services.
3387    let mut needs_compose = false;
3388    for (name, targets) in unit_consumes {
3389        if !targets.is_empty()
3390            && let Some(UnitKind::Context) = kinds.get(name)
3391        {
3392            for t in targets {
3393                if let Some(other) = unit_tables.get(t)
3394                    && !other.services.is_empty()
3395                {
3396                    needs_compose = true;
3397                }
3398            }
3399        }
3400    }
3401    // v0.15: also compose when a context uses a consumed context's capability
3402    // (in a handler or in a provider's `given`) — the consumer must instantiate
3403    // the provided capability's provider locally.
3404    if !needs_compose {
3405        for (name, kind) in kinds {
3406            if *kind != UnitKind::Context {
3407                continue;
3408            }
3409            let Some(table) = unit_tables.get(name) else {
3410                continue;
3411            };
3412            let consumed = unit_consumes.get(name).cloned().unwrap_or_default();
3413            let aliases = unit_consumes_aliases.get(name).cloned().unwrap_or_default();
3414            let flattened = unit_flattened.get(name).cloned().unwrap_or_default();
3415            if !handler_cross_caps(table, &consumed, &aliases, &flattened).is_empty()
3416                || table.providers.values().any(|p| {
3417                    p.given.iter().any(|g| {
3418                        g.is_cross_context()
3419                            // v0.18: a bare given flattened from `consumes U
3420                            // { Cap }` is cross-unit too — its provider lives
3421                            // in the consumed unit.
3422                            || (g.prefix().is_none() && flattened.contains_key(g.key()))
3423                    })
3424                })
3425            {
3426                needs_compose = true;
3427                break;
3428            }
3429        }
3430    }
3431    if !needs_compose {
3432        return None;
3433    }
3434
3435    let mut contexts: Vec<&String> = groups
3436        .keys()
3437        .filter(|n| kinds.get(*n) == Some(&UnitKind::Context))
3438        .collect();
3439    contexts.sort();
3440
3441    // The composeApp body is built first so the provider expressions can
3442    // record every unit namespace they reference (v0.18: an external
3443    // provider's `given` may pull in *another* adapter's binding — the
3444    // transitive given-closure — which must then be imported).
3445    let mut referenced_units: BTreeSet<String> = BTreeSet::new();
3446    let mut out = String::new();
3447
3448    let (compose_params, env_ident) = if thread_env {
3449        ("env?: unknown", Some("env"))
3450    } else {
3451        ("", None)
3452    };
3453    out.push_str(&format!(
3454        "export function composeApp({compose_params}) {{\n"
3455    ));
3456
3457    // Build each context's deps and surface in dependency-respecting order:
3458    // a context that consumes another must come after the consumed context,
3459    // so its `surface` field can reference the already-built surface.
3460    let mut ordered: Vec<String> = Vec::new();
3461    let mut visited: HashSet<String> = HashSet::new();
3462    fn visit(
3463        node: &str,
3464        unit_consumes: &HashMap<String, Vec<String>>,
3465        visited: &mut HashSet<String>,
3466        out: &mut Vec<String>,
3467    ) {
3468        if visited.contains(node) {
3469            return;
3470        }
3471        visited.insert(node.to_string());
3472        if let Some(targets) = unit_consumes.get(node) {
3473            for t in targets {
3474                visit(t, unit_consumes, visited, out);
3475            }
3476        }
3477        out.push(node.to_string());
3478    }
3479    for c in &contexts {
3480        visit(c, unit_consumes, &mut visited, &mut ordered);
3481    }
3482
3483    for ctx_name in &ordered {
3484        if kinds.get(ctx_name.as_str()) != Some(&UnitKind::Context) {
3485            continue;
3486        }
3487        let Some(table) = unit_tables.get(ctx_name.as_str()) else {
3488            continue;
3489        };
3490        // A context's deps object exists only to feed its `makeSurface`; a
3491        // capability-only context (no services) needs neither (v0.15).
3492        if table.services.is_empty() {
3493            continue;
3494        }
3495        let ns = ctx_name.replace('.', "_");
3496
3497        let mut deps_entries: Vec<String> = table
3498            .providers
3499            .keys()
3500            .map(|cap| {
3501                format!(
3502                    "{cap}: {}",
3503                    instantiate_provider_expr(
3504                        ctx_name,
3505                        cap,
3506                        unit_tables,
3507                        unit_consumes,
3508                        unit_consumes_aliases,
3509                        unit_flattened,
3510                        false,
3511                        env_ident,
3512                        &mut referenced_units,
3513                    )
3514                )
3515            })
3516            .collect();
3517        // v0.15: cross-context capabilities used directly by handlers become
3518        // top-level deps fields, instantiated from the providing context.
3519        {
3520            let consumed = unit_consumes
3521                .get(ctx_name.as_str())
3522                .cloned()
3523                .unwrap_or_default();
3524            let aliases = unit_consumes_aliases
3525                .get(ctx_name.as_str())
3526                .cloned()
3527                .unwrap_or_default();
3528            let flattened = unit_flattened
3529                .get(ctx_name.as_str())
3530                .cloned()
3531                .unwrap_or_default();
3532            for (key, cctx) in handler_cross_caps(table, &consumed, &aliases, &flattened) {
3533                deps_entries.push(format!(
3534                    "{key}: {}",
3535                    instantiate_provider_expr(
3536                        &cctx,
3537                        &key,
3538                        unit_tables,
3539                        unit_consumes,
3540                        unit_consumes_aliases,
3541                        unit_flattened,
3542                        false,
3543                        env_ident,
3544                        &mut referenced_units,
3545                    )
3546                ));
3547            }
3548        }
3549        deps_entries.sort();
3550
3551        let mut surface_entries: Vec<String> = Vec::new();
3552        if let Some(targets) = unit_consumes.get(ctx_name.as_str()) {
3553            let aliases = unit_consumes_aliases
3554                .get(ctx_name.as_str())
3555                .cloned()
3556                .unwrap_or_default();
3557            let mut alias_for: HashMap<String, String> = HashMap::new();
3558            for (alias, target) in &aliases {
3559                alias_for.insert(target.clone(), alias.clone());
3560            }
3561            let mut sorted_targets = targets.clone();
3562            sorted_targets.sort();
3563            for t in &sorted_targets {
3564                let Some(other) = unit_tables.get(t) else {
3565                    continue;
3566                };
3567                if other.services.is_empty() {
3568                    continue;
3569                }
3570                let surface_key = alias_for
3571                    .get(t)
3572                    .cloned()
3573                    .unwrap_or_else(|| t.rsplit('.').next().unwrap_or(t.as_str()).to_string());
3574                surface_entries.push(format!("{surface_key}: {}Surface", t.replace('.', "_")));
3575            }
3576        }
3577        if !surface_entries.is_empty() {
3578            deps_entries.push(format!("surface: {{ {} }}", surface_entries.join(", ")));
3579        }
3580        out.push_str(&format!(
3581            "  const {ns}Deps = {{ {} }};\n",
3582            deps_entries.join(", ")
3583        ));
3584        if !table.services.is_empty() {
3585            out.push_str(&format!(
3586                "  const {ns}Surface = {ns}.makeSurface({ns}Deps);\n",
3587            ));
3588        }
3589    }
3590    out.push('\n');
3591
3592    // Export per-context surfaces under a top-level object.
3593    out.push_str("  return {\n");
3594    for ctx_name in &contexts {
3595        let Some(table) = unit_tables.get(ctx_name.as_str()) else {
3596            continue;
3597        };
3598        if table.services.is_empty() {
3599            continue;
3600        }
3601        let ns = ctx_name.replace('.', "_");
3602        let key = ctx_name.rsplit('.').next().unwrap_or(ctx_name.as_str());
3603        out.push_str(&format!("    {key}: {ns}Surface,\n"));
3604    }
3605    out.push_str("  };\n");
3606    out.push_str("}\n");
3607
3608    // Assemble the header now that the body has recorded which units its
3609    // provider expressions reference.
3610    let mut header = String::new();
3611    header.push_str("// Generated by bynkc — do not edit by hand.\n");
3612    header.push_str("// composition root\n\n");
3613
3614    // Import every context as a namespace.
3615    for ctx_name in &contexts {
3616        let dir = emitter::ts_specifier(&commons_dir_for(ctx_name));
3617        let ns = ctx_name.replace('.', "_");
3618        header.push_str(&format!("import * as {ns} from \"./{dir}.js\";\n"));
3619    }
3620    // v0.17: import each consumed adapter's binding module — the external
3621    // provider classes live there, not in the adapter's interface module.
3622    // v0.18: plus every adapter the provider expressions referenced through
3623    // the transitive given-closure (an adapter's external provider may depend
3624    // on another adapter's capability, spec §4.5).
3625    let mut consumed_adapters: Vec<String> = unit_consumes
3626        .iter()
3627        .filter(|(name, _)| kinds.get(*name) == Some(&UnitKind::Context))
3628        .flat_map(|(_, targets)| targets.iter().cloned())
3629        .chain(referenced_units.iter().cloned())
3630        .filter(|t| adapter_bindings.contains_key(t))
3631        .collect();
3632    consumed_adapters.sort();
3633    consumed_adapters.dedup();
3634    for adapter in &consumed_adapters {
3635        let ns = adapter.replace('.', "_");
3636        let module =
3637            emitter::ts_specifier(&adapter_bindings[adapter].output_path.with_extension("js"));
3638        header.push_str(&format!("import * as {ns}__binding from \"./{module}\";\n"));
3639    }
3640    header.push('\n');
3641
3642    let out = format!("{header}{out}");
3643
3644    Some(out)
3645}
3646
3647// -- internals --
3648
3649/// v0.8: collect the boundary-type owners visible to a given consuming
3650/// context. Every consumed-context type and every commons type referenced
3651/// in cross-context positions has an owner; that owner emits the
3652/// serialise/deserialise helpers.
3653fn compute_boundary_type_owners(
3654    consumer: &str,
3655    unit_info: &HashMap<String, UnitInfo>,
3656    parsed: &[ParsedFile],
3657) -> HashMap<String, BoundaryOwner> {
3658    let mut out: HashMap<String, BoundaryOwner> = HashMap::new();
3659    let Some(consumer_info) = unit_info.get(consumer) else {
3660        return out;
3661    };
3662    let _ = parsed;
3663    for t in &consumer_info.consumes {
3664        let Some(target_info) = unit_info.get(t) else {
3665            continue;
3666        };
3667        // Types declared in the consumed context (records, sums, refined,
3668        // opaque) — record them with the consumed context as owner.
3669        for type_name in target_info.table.types.keys() {
3670            out.insert(
3671                type_name.clone(),
3672                BoundaryOwner::Context { context: t.clone() },
3673            );
3674        }
3675        // Commons types `uses`-imported by the consumed context: their
3676        // file lookup is unit_file_index keyed by commons name.
3677    }
3678    // For consumer-side commons types (used in this context's exposed
3679    // signatures), look them up via this consumer's file index.
3680    let _ = &consumer_info.file_index;
3681    out
3682}
3683
3684/// Context passed to the emitter so it can resolve cross-file and
3685/// cross-unit references into TypeScript import statements.
3686pub struct EmitProjectCtx {
3687    /// Source path of the file being emitted (relative to project root).
3688    pub source_path: PathBuf,
3689    /// Joined name of the commons or context this file belongs to.
3690    pub commons_name: String,
3691    /// Sibling files in the same unit (project-relative paths).
3692    pub local_files: Vec<PathBuf>,
3693    /// Which file declares each name in the local unit.
3694    pub file_decl_index: FileDeclIndex,
3695    /// For each imported name, the joined name of the unit it came from.
3696    pub imported_from: HashMap<String, String>,
3697    /// For each imported name, the kind (commons vs context) of the source unit.
3698    pub imported_from_kind: HashMap<String, UnitKind>,
3699    /// For each imported unit, the file path that declares each name.
3700    pub imported_decl_paths: HashMap<String, HashMap<String, PathBuf>>,
3701    /// The directory (project-relative) that holds this unit.
3702    pub commons_dir: PathBuf,
3703    /// What kind of unit this is.
3704    pub unit_kind: UnitKind,
3705    /// For contexts: this context's qualified name (used as the brand for
3706    /// rebranded mixed-in types and exported types).
3707    pub owning_context: Option<String>,
3708    /// For contexts: visibility of types declared in this context.
3709    pub exports_local: HashMap<String, Visibility>,
3710    /// For contexts: exports of each consumed context (so the emitter knows
3711    /// which names to import and how).
3712    pub exports_for_consumed: HashMap<String, HashMap<String, Visibility>>,
3713    /// For contexts: types imported via `consumes` clauses with their
3714    /// visibility and owning-context metadata.
3715    pub consumed_types: HashMap<String, ConsumedType>,
3716    /// For contexts: full cross-context information (consumed contexts,
3717    /// aliases, consumed services and types). Mirrors what the resolver
3718    /// and checker see (v0.6).
3719    pub cross_context: resolver::CrossContextInfo,
3720    /// True when *this* context's surface is consumed by another context in
3721    /// the project. Drives `makeSurface` emission (v0.6 §6.3).
3722    pub is_consumed_by_others: bool,
3723    /// v0.8 build target. Workers mode reroutes cross-context calls through
3724    /// Service Bindings and adds per-Worker entry/composition artefacts.
3725    pub target: BuildTarget,
3726    /// v0.8 (workers mode): for each cross-context type used in cross-context
3727    /// positions, the type's owning context's qualified name. Lets the
3728    /// emitter route serialise/deserialise helper imports to the owning
3729    /// module.
3730    pub boundary_type_owners: HashMap<String, BoundaryOwner>,
3731    /// Agent names declared in this unit. The body lowering uses this set
3732    /// to recognise `Agent(key)` construction and `agent_instance.method(...)`
3733    /// dispatch.
3734    pub local_agents: HashSet<String>,
3735    /// v0.47: the context's actor declarations (merged across files), keyed by
3736    /// name. Used to resolve a handler's Bearer verification seam in `emit.rs`
3737    /// regardless of which file declares the actor.
3738    pub actors: HashMap<String, bynk_syntax::ast::ActorDecl>,
3739    /// v0.17: consumed unit names that are adapters. An adapter is not a Worker,
3740    /// so in workers mode its capability types are imported from its root module
3741    /// (`<adapter>.ts`), not from a per-Worker `handlers.ts`.
3742    pub consumed_adapters: HashSet<String>,
3743    /// Slice 2: the extension emitted import specifiers use (`.js` default; `.ts`
3744    /// for the `bynkc test --inspect` debug build). Consulted by `runtime_import_for`
3745    /// and the sibling/cross-commons specifier helpers.
3746    pub import_ext: ImportExt,
3747}
3748
3749/// Where a boundary-crossing type was declared.
3750#[derive(Debug, Clone)]
3751pub enum BoundaryOwner {
3752    /// Commons type. Path is project-relative to the `.bynk` file declaring it.
3753    Commons { source_path: PathBuf },
3754    /// Context type. Qualified context name (e.g., `commerce.payment`).
3755    Context { context: String },
3756}
3757
3758impl EmitProjectCtx {
3759    pub fn commons_path(name: &str) -> PathBuf {
3760        commons_dir_for(name)
3761    }
3762}
3763
3764#[allow(dead_code)]
3765fn _ensure_components_used(_p: &Path) {
3766    let _ = Component::CurDir;
3767}
3768
3769#[cfg(test)]
3770mod tests {
3771    use super::*;
3772
3773    /// v0.29.4: assembly yields exactly one `UnitInfo` per group, every facet
3774    /// present, with `exports`/`aliases`/`flattened` defaulting to empty for a
3775    /// unit absent from those (genuinely optional) producer maps — reproducing
3776    /// the old `.unwrap_or(empty)` read semantics as a total field.
3777    #[test]
3778    fn assemble_unit_info_yields_one_record_per_group_with_all_facets() {
3779        let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
3780        groups.insert("a.commons".to_string(), vec![0, 1]);
3781        groups.insert("a.context".to_string(), vec![2]);
3782
3783        let mut kinds: HashMap<String, UnitKind> = HashMap::new();
3784        kinds.insert("a.commons".to_string(), UnitKind::Commons);
3785        kinds.insert("a.context".to_string(), UnitKind::Context);
3786
3787        let mut unit_tables: HashMap<String, UnitTable> = HashMap::new();
3788        unit_tables.insert("a.commons".to_string(), UnitTable::default());
3789        unit_tables.insert("a.context".to_string(), UnitTable::default());
3790
3791        let mut unit_uses: HashMap<String, Vec<String>> = HashMap::new();
3792        unit_uses.insert("a.context".to_string(), vec!["a.commons".to_string()]);
3793
3794        let mut unit_consumes: HashMap<String, Vec<String>> = HashMap::new();
3795        unit_consumes.insert("a.context".to_string(), vec![]);
3796
3797        // The genuinely-optional maps deliberately omit `a.commons` so the test
3798        // pins the empty-default behaviour.
3799        let mut unit_flattened: HashMap<String, HashMap<String, String>> = HashMap::new();
3800        unit_flattened.insert("a.context".to_string(), HashMap::new());
3801        let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = HashMap::new();
3802        let mut exports_visibility: HashMap<String, HashMap<String, Visibility>> = HashMap::new();
3803        exports_visibility.insert("a.context".to_string(), HashMap::new());
3804
3805        let mut unit_file_index: HashMap<String, FileDeclIndex> = HashMap::new();
3806        unit_file_index.insert(
3807            "a.commons".to_string(),
3808            FileDeclIndex {
3809                types: HashMap::new(),
3810                fns: HashMap::new(),
3811                methods: HashMap::new(),
3812            },
3813        );
3814        // `a.context` is absent from the file index → its `file_index` defaults.
3815
3816        let info = assemble_unit_info(
3817            &groups,
3818            &kinds,
3819            &unit_tables,
3820            &unit_uses,
3821            &unit_consumes,
3822            &unit_flattened,
3823            &unit_consumes_aliases,
3824            &exports_visibility,
3825            &unit_file_index,
3826        );
3827
3828        // One record per group, no more.
3829        assert_eq!(info.len(), 2);
3830        assert!(info.contains_key("a.commons"));
3831        assert!(info.contains_key("a.context"));
3832
3833        // `files` mirrors the `groups` indices.
3834        assert_eq!(info["a.commons"].files, vec![0, 1]);
3835        assert_eq!(info["a.context"].files, vec![2]);
3836
3837        // Non-optional facets are filled from their producer maps.
3838        assert_eq!(info["a.commons"].kind, UnitKind::Commons);
3839        assert_eq!(info["a.context"].kind, UnitKind::Context);
3840        assert_eq!(info["a.context"].uses, vec!["a.commons".to_string()]);
3841
3842        // Optional facets default to empty for the unit with no entry.
3843        assert!(info["a.commons"].exports.is_empty());
3844        assert!(info["a.commons"].aliases.is_empty());
3845        assert!(info["a.commons"].flattened.is_empty());
3846        // And the absent `file_index` is an empty index, not a panic.
3847        assert!(info["a.context"].file_index.types.is_empty());
3848        assert!(info["a.context"].file_index.fns.is_empty());
3849        assert!(info["a.context"].file_index.methods.is_empty());
3850    }
3851}