Skip to main content

bynk_check/
analysis.rs

1//! The project-level analysis entry point (P4.1, #1115): discovery
2//! (`bynk-project`) → parse → resolve → check, returning the `bynk-ide`-facing
3//! analogue of `bynk-emit`'s `ProjectAnalysis` — without ever emitting.
4//!
5//! `bynk-ide` is repointed at [`analyse_project`] as of P4.2 (#1122) —
6//! `bynk_emit::project::analyse_project_with` was no longer reachable from
7//! `bynk-ide` from that point on (it had no `bynk-emit` dependency left).
8//! This entry point is what every real caller uses; #1541 deleted
9//! `analyse_project_with` and its single-tree wrapper `analyse_project`
10//! outright once nothing but `bynk-check/tests/differential_analysis.rs`'s
11//! own comparison still called them — that fixture is now a golden test of
12//! this entry point alone, not a differential against the retired one.
13//!
14//! ## The residual gap
15//!
16//! This entry point was diagnostically faithful to `bynk-emit`'s
17//! `run_checks`'s `Mode::Analyse` arm **minus seven categories** of
18//! whole-project checking at P4.2 (recorded on the tracking issue's own
19//! scope-correction comments, not silently assumed). Categories 2, 3, 4 and 6
20//! closed at P5.0/P5.1/P5.2 (`design/tracks/semantics-in-the-checker.md` §6)
21//! — [`crate::project_model::phase_messages_bundles`]/
22//! [`crate::project_model::phase_locale_bundle_ambiguity`]/
23//! [`crate::project_model::phase_event_subscriptions`]/
24//! [`crate::project_model::phase_function_type_boundaries`] are now called
25//! from [`analyse_project`] at the same points `run_checks` calls them.
26//! Categories 1 and 5 closed at P5.3, structurally rather than observably —
27//! both were already unreachable from the editor before P4.2 even shipped,
28//! so porting them changed nothing observable. Category 7 closed at P5.4, the
29//! last of the seven and the one this doc comment's own author flagged as
30//! needing more care (§9 of the design doc) — see below. All seven
31//! categories are now closed:
32//!
33//! 1. ~~Schema-registry reconciliation~~ — **closed at P5.3**.
34//!    [`crate::schema_registry::reconcile`] is now called from
35//!    [`analyse_project`], right after [`crate::project_model::phase_validate_providers`]
36//!    (the same relative point `run_checks` calls it). Unreachable on this
37//!    path until #2081, which gave [`analyse_project`] a `schema_lock`
38//!    argument: the LSP pre-reads `bynk.schema.lock` and passes it in, so a
39//!    non-additive change now reaches the editor as it reaches `bynk check`.
40//! 2. ~~`messages` bundle validation~~ — **closed at P5.0**, see above.
41//! 3. ~~Locale bundle ambiguity~~ — **closed at P5.0**, see above.
42//! 4. ~~Event-subscription validation~~ — **closed at P5.1**, see above.
43//! 5. ~~Platform-lock enforcement~~ — **closed at P5.3**.
44//!    [`crate::project_model::phase_platform_lock`] is now called from
45//!    [`analyse_project`], right after the per-unit compose/check loop (the
46//!    same relative point `run_checks` calls it, gated the same way on a
47//!    clean error sink so far). Still unreachable on this path, for the same
48//!    reason as before the relocation: `analyse_project` hardcodes
49//!    `Platform::default()` (Cloudflare) and `BuildTarget::Bundle`, and
50//!    `bynk.cloudflare` is the only platform-native unit that exists
51//!    (`firstparty::platform_of`) — so `lock_violation` can never find a
52//!    native platform disagreeing with the selected one, for any project, on
53//!    this path. No fixture can observe this category regressing (or
54//!    improving) because it never fired through this path to begin with, both
55//!    before and after this relocation.
56//! 6. ~~Function-type-boundary checks~~ — **closed at P5.2**. Formerly reached,
57//!    in `bynk-emit`, only through `phase_group`'s optional boundary-check
58//!    hook (`Some` from `run_checks`, `None` here); the hook is gone —
59//!    [`crate::project_model::phase_group`] now calls
60//!    [`crate::project_model::phase_function_type_boundaries`] directly, at
61//!    the exact point the hook used to fire, so both callers see it in the
62//!    same diagnostic-ordering position as before.
63//! 7. ~~Test/integration-suite processing~~
64//!    (`process_tests`/`process_integration_tests`) — **closed at P5.4**.
65//!    Unlike categories 2-6, these run *unconditionally* in `run_checks`, in
66//!    `Mode::Analyse` too, and push into the same shared error sink (`bynk-emit`'s own
67//!    `check_project_reports_a_test_body_error_past_an_earlier_structural_error`
68//!    pins a `bynk.types.let_annotation_mismatch` originating inside a
69//!    `suite`/`test integration` body). The two functions were emission-coupled
70//!    (`Document`, `RunnableTest`, `ImportExt`, `contracts`, a shared
71//!    `emitted_barrels` set) deeply enough that P5.4 split them at the
72//!    check/emit boundary rather than porting the whole thing: their checking
73//!    half relocated to [`crate::test_suites::phase_test_bodies`]/
74//!    [`crate::test_suites::phase_integration_bodies`], now called from
75//!    [`analyse_project`] right after the per-unit compose/check loop (the
76//!    same relative point `run_checks` calls the originals, unconditionally —
77//!    unlike categories 2-6, neither is gated on a clean error sink), while
78//!    emission itself stays in `bynk-emit::project::tests_emit`, which now
79//!    calls the relocated checking phase too rather than duplicating it. Both
80//!    functions still take `&mut RefSink`, so every binding edge inside a
81//!    `.bynk` suite file is populated here again too — go-to-definition
82//!    inside a test file works through this entry point once more.
83//!
84//! Emission itself is orthogonal rather than a gap: this entry point never
85//! emits, by construction (it has no `BuildTarget`/`ImportExt`/`contracts`
86//! concept at all), so there is no diagnostic-agreement question to ask of it.
87//!
88//! A fixture that exercises none of the seven categories above saw identical
89//! diagnostics from this entry point and from `analyse_project_with` while
90//! both existed — that is why `bynk-check/tests/differential_analysis.rs`'s
91//! clean/broken golden cases still avoid all seven (#1541 retired the
92//! comparison, not the fixture shape). A third case
93//! (`new_entry_point_reports_a_test_bodys_own_type_error`, renamed from
94//! `new_entry_point_omits_test_body_diagnostics` at #1541) pinned category
95//! 7's divergence directly; now that P5.4 closed it, that test pins parity
96//! with what `analyse_project_with` used to report instead (see its own doc
97//! comment).
98//!
99//! ## Two sites outside the seven-category accounting
100//!
101//! `bynk-check/src/analysis.rs`'s own seven categories were `run_checks`'s
102//! whole-project checks; two more registered diagnostics were still
103//! constructed in `bynk-emit` and outside that accounting, found and closed
104//! at P5.5 (`design/tracks/semantics-in-the-checker.md` §6, §9):
105//!
106//! - `bynk.project.schema_registry_corrupt` — a malformed on-disk
107//!   `bynk.schema.lock`. [`crate::schema_registry::parse_or_diagnose`] now
108//!   constructs it. Reachable from this entry point since #2081, under the
109//!   caller's `SchemaLock::On`, same as category 1.
110//! - `bynk.secrets.computed_name` — see
111//!   [`crate::project_model::phase_secrets_computed_name`]'s own doc. Unlike
112//!   the seven categories (scoped and confirmed live gaps or confirmed
113//!   gap-in-name-only by this settling pass), this one's reachability from
114//!   *this* entry point was still open at settling time — §9 named it a risk
115//!   rather than a scoped item. It resolved the same way categories 1 and 5
116//!   did: gap-in-name-only, since `run_checks`'s own gate
117//!   (`target == BuildTarget::Workers`) can never pass against this entry
118//!   point's hardcoded `BuildTarget::Bundle`.
119
120use std::collections::{HashMap, HashSet};
121use std::path::{Path, PathBuf};
122use std::sync::Arc;
123
124use crate::check_pipeline::{check_file_core, prepare_unit_check_ctx, record_analyse_types};
125use crate::checker::Types;
126use crate::expr_types::{ExprTypeSink, FileExprTypes};
127use crate::firstparty::Platform;
128use crate::hints::{FileHints, HintSink};
129use crate::index::{ProjectIndex, RefSink};
130use crate::locals::{FileLocals, LocalsSink};
131use crate::project_model::{
132    self, ErrorSink, assemble_unit_info, close_reachable_types, collect_unit_methods,
133    compose_unit_symbols, merge_consumed_exports, normalize_service_defaults,
134};
135use crate::requirements::{FileRequirements, RequirementSink};
136use crate::symbols::{assemble_index, build_cross_context_info};
137use bynk_project::{AttributedError, Roots, SchemaLock, UnitKind};
138use bynk_syntax::ast::{AgentDecl, ServiceDecl, TypeDecl};
139
140/// #846: the per-unit slice of resolution the sequence-diagram classifier
141/// needs — see [`ProjectAnalysis::sequence_info`]. Moved verbatim (Decision
142/// C, #1115) from `bynk-emit/src/project/diagnostics.rs`.
143#[derive(Debug, Clone, Default)]
144pub struct ContextSequenceInfo {
145    pub cross_context: crate::resolver::CrossContextInfo,
146    pub agents: HashMap<String, AgentDecl>,
147}
148
149/// #855: the per-unit slice of resolution the wire-contract peek needs —
150/// see [`ProjectAnalysis::boundary_info`]. A sibling of
151/// [`ContextSequenceInfo`], not a field on it: that struct is named and
152/// documented for #846, and this is a separate retained table serving a
153/// separate query (hover/panel over a single handler's boundary, not the
154/// sequence-diagram classifier). Moved verbatim (Decision C, #1115).
155#[derive(Debug, Clone, Default)]
156pub struct ContextBoundaryInfo {
157    /// `contract_types_for` (#2073): the unit's own declared types, the types
158    /// of every commons it `uses` and those of the contexts it consumes — the
159    /// same table `own_contract_hashes` hashes through, so the peek's hash and
160    /// the emitted `X-Bynk-Contract` constant cannot disagree.
161    pub types: HashMap<String, Arc<TypeDecl>>,
162    pub services: HashMap<String, ServiceDecl>,
163    pub agents: HashMap<String, AgentDecl>,
164}
165
166/// v0.24: the analyse-mode result — every discovered file's analysed text
167/// snapshot (positions must convert against the text that was analysed, not
168/// a newer buffer) plus the attributed diagnostics. Moved verbatim (Decision
169/// C, #1115) from `bynk-emit/src/project/diagnostics.rs`; `bynk-emit`
170/// re-exports this type at its old path (`bynk_emit::project::ProjectAnalysis`)
171/// so `bynk-ide`'s existing destructuring needs no field-by-field rewrite.
172pub struct ProjectAnalysis {
173    /// `(project-relative source path, analysed text)` for every file read,
174    /// including clean files (the LSP needs them to clear diagnostics).
175    pub snapshots: Vec<(PathBuf, String)>,
176    pub errors: Vec<AttributedError>,
177    /// v0.25 (ADR 0053): the project-wide binding index. Empty when the
178    /// pipeline bails before resolution (discovery/parse failures).
179    pub index: ProjectIndex,
180    /// v0.27 (ADR 0056): per-file inferred-type inlay hints — `(binding-name
181    /// span, label)`, span-ordered, harvested from the checker's binding
182    /// sites. Empty for files the pipeline never type-checked.
183    pub hints: FileHints,
184    /// v0.30.2 (ADR 0063): per-file expression types — `(expr span, Ty)`,
185    /// for `.`-member completion's and signature help's receiver typing.
186    /// Recorded at every per-file exit of the check, clean or not: a file
187    /// with errors still carries the best-effort partial types the checker
188    /// computed for its other expressions (ADR 0094, which lifted ADR 0063's
189    /// clean-file ceiling — see [`record_analyse_types`]).
190    /// Empty for files the pipeline never type-checked and for synthetic
191    /// files (muted).
192    pub expr_types: FileExprTypes,
193    /// T3.6b (R4.1): the intern table every `TyId` in `expr_types` resolves
194    /// against — one table shared across the whole analysis.
195    pub ty_intern: Arc<Types>,
196    /// v0.31 (ADR 0064): per-file local bindings with their scope ranges —
197    /// `let`/`let <-`, fn/handler/lambda params — for the scope-at-offset
198    /// query backing locals completion + navigation. Synthetic files muted.
199    pub locals: FileLocals,
200    /// v0.99: per-file capability-requirement ledger — every
201    /// capability-consuming site (direct call, store op), covered or not,
202    /// with its provenance. Empty for files the pipeline never type-checked,
203    /// and for synthetic/test files (muted).
204    pub requirements: FileRequirements,
205    /// Slice 6b (ADR 0095): qualified unit name → the project source file(s)
206    /// that comprise it, in discovery order. Excludes synthetic
207    /// (toolchain-injected) units; empty when the pipeline bails before the
208    /// checker.
209    pub unit_sources: HashMap<String, Vec<PathBuf>>,
210    /// #846: qualified context/adapter unit name → the cross-context and
211    /// agent tables needed to classify a handler call as a lifeline for the
212    /// sequence-diagram query. Only contexts/adapters have an entry; empty
213    /// when the pipeline bails before the checker.
214    pub sequence_info: HashMap<String, ContextSequenceInfo>,
215    /// #855: qualified context/adapter unit name → the combined type table
216    /// and per-context service/agent tables the wire-contract peek needs.
217    /// Only contexts/adapters have an entry; empty when the pipeline bails
218    /// before the checker.
219    pub boundary_info: HashMap<String, ContextBoundaryInfo>,
220    /// #848: qualified unit name → its doc-comment intra-doc-link search
221    /// order — itself first, then its `uses` targets, then its `consumes`
222    /// targets, in that order. Empty when the pipeline bails before the
223    /// checker.
224    pub doc_scope: HashMap<String, Vec<String>>,
225}
226
227/// The `ProjectAnalysis` shape for every "bailed before the checker ran"
228/// exit — discovery/file-conflict/parse failures. Factored out so the three
229/// early-return sites in [`analyse_project`] agree by construction, the same
230/// role `RunChecks::Bailed` plays in `bynk-emit`'s `run_checks`.
231fn bailed(
232    errors: ErrorSink,
233    snapshots: Vec<(PathBuf, String)>,
234    mut hints: HintSink,
235    mut locals: LocalsSink,
236    mut exprs: ExprTypeSink,
237    mut requirements: RequirementSink,
238    tys: &Arc<Types>,
239) -> ProjectAnalysis {
240    ProjectAnalysis {
241        snapshots,
242        // ADR 0117: the LSP renders warnings alongside errors (severity is
243        // applied downstream), so analyse surfaces the full diagnostic list.
244        errors: errors.into_all(),
245        index: ProjectIndex::default(),
246        hints: hints.take_files(),
247        locals: locals.take_files(),
248        expr_types: exprs.take_files(),
249        ty_intern: Arc::clone(tys),
250        requirements: requirements.take_files(),
251        unit_sources: HashMap::new(),
252        sequence_info: HashMap::new(),
253        boundary_info: HashMap::new(),
254        doc_scope: HashMap::new(),
255    }
256}
257
258/// The `bynk-check`-native discovery→parse→resolve→check entry point (P4.1,
259/// #1115) — see this module's own doc comment for the residual-gap
260/// accounting against `bynk-emit`'s now-deleted (#1541) `analyse_project_with`.
261/// Mirrored its call shape exactly where the two overlapped:
262/// `BuildTarget::Bundle`-equivalent (this entry point has no build target at
263/// all — it never emits), `Platform::default()`.
264///
265/// `schema_lock` (#2081) is `bynk.schema.lock`'s content, pre-read by the
266/// caller exactly as `bynk check` pre-reads it (this crate reads no lock from
267/// disk). Under [`SchemaLock::On`] every event is reconciled against it and a
268/// non-additive change, a corrupt lock or a mismatched `@schema(N)` is
269/// reported as the build reports it; under [`SchemaLock::Off`] reconciliation
270/// is skipped. Nothing is written either way.
271///
272/// Identity is project-relative (ADR 0198): a file's `source_path` here is
273/// unique across `include` roots, same as `analyse_project_with` used to be.
274pub fn analyse_project(
275    roots: &Roots,
276    overlay: &HashMap<PathBuf, String>,
277    schema_lock: &SchemaLock,
278) -> ProjectAnalysis {
279    // S3 (#1963): the project's `[unstable]` opt-in, read the way the build
280    // reads it, so the editor and `bynkc` agree about every file.
281    let policy =
282        crate::stability::StabilityPolicy::for_project(Some(roots.project_root()), overlay);
283    analyse_project_with_policy(roots, overlay, policy, schema_lock)
284}
285
286/// [`analyse_project`] under a given [`crate::stability::StabilityPolicy`] —
287/// how a test treats a shipped item as experimental, since none is yet.
288pub fn analyse_project_with_policy(
289    roots: &Roots,
290    overlay: &HashMap<PathBuf, String>,
291    policy: crate::stability::StabilityPolicy,
292    schema_lock: &SchemaLock,
293) -> ProjectAnalysis {
294    let tys = &Arc::new(Types::new());
295    // #2223: a manifest-backed project's path dependencies are walked too.
296    let packages = matches!(roots, Roots::Split { .. });
297    let mut trees = roots.trees();
298    if packages {
299        trees.extend(bynk_project::dependency_trees(
300            roots.project_root(),
301            overlay,
302        ));
303    }
304    let excludes = roots.excludes();
305    let policy = Arc::new(policy);
306
307    let mut errors = ErrorSink::new();
308    for err in policy.manifest_diagnostics() {
309        errors.push_for(Some(Path::new(crate::stability::MANIFEST)), err);
310    }
311    let mut refs = RefSink::new();
312    let mut hints = HintSink::new();
313    let mut locals = LocalsSink::new();
314    let mut requirements = RequirementSink::new();
315    let mut exprs = ExprTypeSink::new();
316    let mut snapshots: Vec<(PathBuf, String)> = Vec::new();
317
318    // -- 1. Discovery. --
319    let file_lists = match project_model::phase_discovery(&trees, &excludes, &mut errors) {
320        Ok(files) => files,
321        Err(()) => return bailed(errors, snapshots, hints, locals, exprs, requirements, tys),
322    };
323    if project_model::check_discovered_files(&trees, &file_lists, &mut errors).is_err() {
324        return bailed(errors, snapshots, hints, locals, exprs, requirements, tys);
325    }
326
327    // -- 2. Parse. --
328    let (mut parsed, injected, broken) =
329        match project_model::phase_parse(&trees, &file_lists, overlay, &mut errors, &mut snapshots)
330        {
331            Ok(out) => out,
332            Err(()) => return bailed(errors, snapshots, hints, locals, exprs, requirements, tys),
333        };
334
335    // -- 2a. #2223: fold the project's path dependencies in. --
336    if packages {
337        crate::packages::apply_graph(
338            Some(roots.project_root()),
339            overlay,
340            &mut parsed,
341            true,
342            &mut errors,
343        );
344    }
345
346    // -- 2b. Normalize service-level `by`/`given` defaults (v0.155). --
347    normalize_service_defaults(&mut parsed);
348    let parsed = parsed;
349
350    // -- 3. Group. P5.2: closes category 6 of this module's own residual-gap
351    //       accounting (see doc comment above) — `phase_group` now also
352    //       confines function types to non-boundary positions directly, at
353    //       the point its old optional hook used to fire. --
354    let (groups, kinds, test_groups, integration_groups, _adapter_bindings, _npm_deps) =
355        project_model::phase_group(
356            &parsed,
357            &trees,
358            Platform::default(),
359            &injected,
360            overlay,
361            &mut errors,
362        );
363    // #2207: `[package] exports` names only commons and adapters this project
364    // declares, read the way the build reads it.
365    for err in crate::exports::manifest_diagnostics(Some(roots.project_root()), overlay, &kinds) {
366        errors.push_for(Some(Path::new(crate::stability::MANIFEST)), err);
367    }
368
369    // -- 4. Per-unit combined symbol tables. --
370    let unit_tables = project_model::phase_symbol_tables(&groups, &kinds, &parsed, &mut errors);
371
372    // -- 5. `uses` resolution. --
373    let unit_uses = project_model::phase_resolve_uses(
374        &groups,
375        &kinds,
376        &parsed,
377        &unit_tables,
378        &policy,
379        &mut errors,
380    );
381
382    // -- 5b. `consumes` resolution. --
383    let (unit_consumes, unit_flattened) = project_model::phase_resolve_consumes(
384        &groups,
385        &kinds,
386        &parsed,
387        &unit_tables,
388        &policy,
389        &mut errors,
390        &mut refs,
391    );
392    let mut unit_tables = unit_tables;
393    crate::symbols::record_flattened_caps(&mut unit_tables, &unit_flattened);
394    crate::project_model::phase_resolve_derived(
395        &groups,
396        &kinds,
397        &parsed,
398        &mut unit_tables,
399        &mut errors,
400    );
401    let unit_tables = unit_tables;
402
403    // -- 5b'. `consumes` aliases. --
404    let unit_consumes_aliases =
405        project_model::phase_consumes_aliases(&groups, &kinds, &parsed, &unit_tables, &mut errors);
406
407    // -- 5b''. v0.173 (ADR 0196 D1), P5.5 (`design/tracks/semantics-in-the-checker.md`
408    //          §6, §9): warn where a `bynk.Secrets` read names its secret with
409    //          a computed expression — closes the "ninth gap" that §9 flagged
410    //          as unresolved risk rather than a scoped relocation. Mirrors
411    //          `run_checks`'s own call at the same relative point. Gated on
412    //          the Workers target, same as `run_checks`; this entry point
413    //          hardcodes `BuildTarget::Bundle` (inherited from the
414    //          now-deleted `analyse_project_with`'s own hardcoding, see this
415    //          function's doc comment), so the call
416    //          closes the category structurally (R3.5 — the diagnostic now
417    //          originates in `bynk-check`), not observably, the same as
418    //          categories 1 and 5. --
419    project_model::phase_secrets_computed_name(
420        project_model::BuildTarget::Bundle,
421        &parsed,
422        &groups,
423        &kinds,
424        &unit_flattened,
425        &mut errors,
426    );
427    // #1822: Workers-only, so a no-op on this Bundle path, called for the
428    // same structural reason as the phase above.
429    project_model::phase_workers_unprovided_capabilities(
430        project_model::BuildTarget::Bundle,
431        &parsed,
432        &groups,
433        &kinds,
434        &unit_tables,
435        &mut errors,
436    );
437    // #2306: Workers-only as well.
438    project_model::phase_workers_ws_open_split(
439        project_model::BuildTarget::Bundle,
440        &parsed,
441        &groups,
442        &kinds,
443        &unit_tables,
444        &mut errors,
445    );
446    // #2355 follow-up: Workers-only as well.
447    project_model::phase_workers_provider_agent_cross_context(
448        project_model::BuildTarget::Bundle,
449        &parsed,
450        &groups,
451        &kinds,
452        &unit_tables,
453        &unit_consumes,
454        &unit_consumes_aliases,
455        &unit_flattened,
456        &mut errors,
457    );
458
459    // -- 5c. `consumes` cycles. --
460    project_model::phase_detect_consumes_cycles(&groups, &parsed, &unit_consumes, &mut errors);
461
462    // -- 6. `uses` name-conflict detection. --
463    project_model::phase_uses_name_conflicts(
464        &unit_uses,
465        &unit_tables,
466        &parsed,
467        &groups,
468        &mut errors,
469    );
470
471    // -- 6a0. #1883, slice 1b: every declared asset names a file in the
472    //         `assets/` folder, listed from disk as discovery already reads
473    //         it. An unreadable folder skips the check rather than flagging
474    //         every asset in the editor.
475    let available_assets = bynk_project::assets::list_asset_files(roots.project_root()).ok();
476    crate::assets::phase_asset_files(&parsed, available_assets.as_ref(), &mut errors);
477    // #2387, slice 2: likewise every declared embed names a file in `embeds/`.
478    let available_embeds = bynk_project::assets::list_embed_files(roots.project_root()).ok();
479    crate::embeds::phase_embed_files(&parsed, available_embeds.as_ref(), &mut errors);
480    crate::assets::phase_asset_routes(&parsed, &groups, &kinds, &unit_uses, &mut errors);
481
482    // -- 6a'. message-bundles slice 1 (#859): messages-block legality,
483    //         @reference cardinality, within-block duplicate codes, and the
484    //         `uses bynk.locale` dependency. P5.0: closes category 2 of this
485    //         module's own residual-gap accounting. --
486    project_model::phase_messages_bundles(&parsed, &groups, &kinds, &unit_uses, &mut errors);
487
488    // -- 6a''. Locale capability track, slice 2 (#882): a context reaching
489    //          two or more message-bundle commons while consuming `Locale`
490    //          has no single bundle to negotiate against. P5.0: closes
491    //          category 3. --
492    project_model::phase_locale_bundle_ambiguity(
493        &parsed,
494        &groups,
495        &kinds,
496        &unit_uses,
497        &unit_flattened,
498        &mut errors,
499    );
500
501    // -- 6a'''. Events track, slice 0 (spine #936): a `from Events(E)`
502    //           subscription must name a real, declared event — needs
503    //           `unit_tables` + `unit_consumes` together, so it runs here
504    //           rather than in the per-context `check_service_protocols`.
505    //           P5.1: closes category 4. --
506    project_model::phase_event_subscriptions(
507        &parsed,
508        &groups,
509        &kinds,
510        &unit_tables,
511        &unit_consumes,
512        &unit_uses,
513        &mut errors,
514    );
515
516    // -- 6b. Type exports. --
517    let exports_visibility = project_model::phase_validate_type_exports(
518        &groups,
519        &kinds,
520        &parsed,
521        &unit_tables,
522        &mut errors,
523        &mut refs,
524    );
525
526    // -- 6b'. Capability exports. --
527    project_model::phase_validate_capability_exports(
528        &groups,
529        &kinds,
530        &parsed,
531        &unit_tables,
532        &mut errors,
533        &mut refs,
534    );
535
536    // -- 6c. Provider matching. --
537    project_model::phase_validate_providers(
538        &unit_tables,
539        &groups,
540        &parsed,
541        &broken,
542        &mut errors,
543        tys,
544    );
545
546    // -- 6d. Events track, slice 3c (#980): schema-registry reconciliation,
547    //        at the same point `run_checks` calls it and with the same two
548    //        arms. #2081: the caller (the LSP) now pre-reads
549    //        `bynk.schema.lock` and hands it in, so a non-additive change, a
550    //        corrupt lock or a mismatched `@schema(N)` reaches the editor as
551    //        it reaches `bynk check`. `Off` (no lock, or a caller with none)
552    //        skips the sweep entirely rather than reconciling against an
553    //        empty registry, which can report nothing — closing review
554    //        #1133's cost note about paying for it on every keystroke. The
555    //        reconciled registry is discarded: analysis never writes. --
556    if let SchemaLock::On { existing } = schema_lock {
557        match crate::schema_registry::parse_or_diagnose(existing.as_deref(), roots.project_root()) {
558            Ok(existing_reg) => {
559                let mut schema_errors: Vec<bynk_syntax::error::CompileError> = Vec::new();
560                let (mut updated, _) = crate::schema_registry::reconcile(
561                    &existing_reg,
562                    &unit_tables,
563                    &mut schema_errors,
564                );
565                errors.extend_for(None, schema_errors);
566                // State-migrations S0 (#2204): agent state shapes, as
567                // `run_checks` reconciles them.
568                let mut agent_errors: Vec<bynk_syntax::error::CompileError> = Vec::new();
569                crate::schema_registry::reconcile_agents(
570                    &existing_reg,
571                    &mut updated,
572                    &unit_tables,
573                    &unit_uses,
574                    &mut agent_errors,
575                );
576                errors.extend_attributed_by_span(&parsed, agent_errors);
577            }
578            Err(err) => errors.push_for(None, err),
579        }
580    }
581
582    // No bail gate: this entry point never bails after discovery (mirrors
583    // `Mode::Analyse` — independent unit groups resolve/check past another
584    // group's errors).
585
586    // -- 7. Per-unit file index. --
587    let unit_file_index = project_model::phase_file_index(&groups, &parsed);
588
589    // -- 7b. Assemble per-unit info. --
590    let unit_info = assemble_unit_info(
591        &groups,
592        &kinds,
593        &unit_tables,
594        &unit_uses,
595        &unit_consumes,
596        &unit_flattened,
597        &unit_consumes_aliases,
598        &exports_visibility,
599        &unit_file_index,
600    );
601
602    // -- 8. For each unit, compose the symbol space and resolve+check every
603    //       file. Test/integration processing is the residual gap after this
604    //       loop — see this module's own doc comment. Category 5
605    //       (platform-lock) closes right after, below the loop, at the same
606    //       relative point `run_checks` calls it (after its own per-unit
607    //       checking, gated on a clean error sink so far). --
608    // #1702 review: `uses` targets first, so a generic callee's compared type
609    // parameters are known before an importer's calls are checked.
610    crate::checker::reset_compared_cache();
611    for name in project_model::uses_first_order(unit_info.keys(), &unit_uses) {
612        let info = &unit_info[name];
613        let kind = info.kind;
614        let indices = info.files.as_slice();
615        let local_table = &info.table;
616        let group_error_baseline = errors.len();
617
618        let (
619            mut combined_types,
620            combined_fns,
621            mut combined_methods,
622            mut imported_from,
623            mut imported_from_kind,
624        ) = compose_unit_symbols(name, local_table, &unit_info);
625        let consumed_types = merge_consumed_exports(
626            name,
627            &parsed,
628            &unit_info,
629            &mut combined_types,
630            &mut combined_methods,
631            &mut imported_from,
632            &mut imported_from_kind,
633            &mut errors,
634        );
635        // #1807: close over the types the imported declarations reach.
636        let reached_types = close_reachable_types(
637            name,
638            &parsed,
639            &unit_info,
640            &mut combined_types,
641            &combined_fns,
642            &mut combined_methods,
643            &mut imported_from,
644            &mut imported_from_kind,
645            &mut errors,
646        );
647
648        if errors.len() > group_error_baseline {
649            continue;
650        }
651
652        let local_names: HashSet<String> = local_table.types.keys().cloned().collect();
653        let local_methods_for_type = collect_unit_methods(indices, &parsed);
654        let ctx = prepare_unit_check_ctx(
655            name,
656            kind,
657            &broken,
658            &unit_info,
659            &combined_types,
660            &imported_from_kind,
661            &reached_types,
662            &policy,
663        );
664
665        for &i in indices {
666            let pf = &parsed[i];
667            if let Some(crate::check_pipeline::FileCheckResult { typed, .. }) = check_file_core(
668                name,
669                kind,
670                pf,
671                &unit_info,
672                &combined_types,
673                &combined_fns,
674                &combined_methods,
675                &local_names,
676                &local_methods_for_type,
677                &consumed_types,
678                &imported_from,
679                &ctx,
680                &mut errors,
681                &mut refs,
682                &mut hints,
683                &mut locals,
684                &mut exprs,
685                &mut requirements,
686                tys,
687            ) {
688                // This entry point never emits — every clean file's exit is
689                // the Analyse-mode one (record best-effort/final types, move
690                // on to the next file). Mirrors `check_unit_files`'s own
691                // `mode == Mode::Analyse` clean-path branch.
692                record_analyse_types(
693                    &mut exprs,
694                    &pf.identity_path(),
695                    pf.is_synthetic(),
696                    &typed.expr_types,
697                );
698            }
699        }
700    }
701
702    // P5.4 (`design/tracks/semantics-in-the-checker.md` §6): test/
703    // integration-suite processing — closes category 7 of this module's own
704    // residual-gap accounting, the last of the seven. Mirrors `run_checks`'s
705    // own call shape: both run unconditionally (unlike categories 2-6 above,
706    // `run_checks` never gates these on a clean error sink), right after its
707    // own per-unit `check_unit_files` loop and before platform-lock — the
708    // same relative point this loop just occupied. Neither function's
709    // returned "ready for emission" map is needed here — this entry point
710    // never emits — only the diagnostic/`RefSink` side effects matter, so
711    // both are discarded. Diagnostics are file-unattributed (`extend_for(None,
712    // ...)`), matching `run_checks`'s own `#696`-noted gap (attributing them
713    // means threading a file through many internal push sites — out of scope
714    // here, same as there).
715    let mut test_errors: Vec<bynk_syntax::error::CompileError> = Vec::new();
716    let _ready_tests = crate::test_suites::phase_test_bodies(
717        &test_groups,
718        &parsed,
719        &kinds,
720        &unit_tables,
721        &exports_visibility,
722        &unit_consumes,
723        &unit_consumes_aliases,
724        &unit_uses,
725        &mut test_errors,
726        &mut refs,
727        tys,
728    );
729    errors.extend_attributed_by_span(&parsed, test_errors);
730
731    let mut integration_errors: Vec<bynk_syntax::error::CompileError> = Vec::new();
732    let _ready_integration = crate::test_suites::phase_integration_bodies(
733        &integration_groups,
734        &parsed,
735        &kinds,
736        &unit_tables,
737        &unit_consumes,
738        &unit_consumes_aliases,
739        &unit_uses,
740        &mut integration_errors,
741        &mut refs,
742        tys,
743    );
744    errors.extend_attributed_by_span(&parsed, integration_errors);
745
746    // v0.19 (decisions 0017/0024), P5.3: platform-lock enforcement — closes
747    // category 5 of this module's own residual-gap accounting. Inherited from
748    // the now-deleted `analyse_project_with`'s own hardcoded
749    // `Platform::default()` (Cloudflare) and `BuildTarget::Bundle`:
750    // `bynk.cloudflare` is the only
751    // platform-native unit that exists, and it matches the default
752    // selection, so `lock_violation` can never fire here, for any project
753    // (see `bynk-lsp/tests/analysis_residual_gap.rs`'s
754    // `platform_lock_diagnostic_stays_absent`) — this call closes the
755    // category structurally (R3.5), not observably.
756    //
757    // Cost (review #1133): a full provider-closure walk per context, same
758    // shape as `run_checks`'s own gate, for a diagnostic that is provably
759    // dead here. `collect_given_closure` is also unmemoised — a
760    // diamond-shaped provider graph re-walks shared subtrees, so this is
761    // worse than linear in the closure's depth, not just wasted. Same
762    // trade-off and same "worth profiling if it ever shows up" note as the
763    // schema-registry reconciliation call above.
764    if errors.is_empty() {
765        project_model::phase_platform_lock(
766            project_model::BuildTarget::Bundle,
767            Platform::default(),
768            &parsed,
769            &groups,
770            &kinds,
771            &unit_tables,
772            &unit_consumes,
773            &unit_consumes_aliases,
774            &unit_flattened,
775            &mut errors,
776        );
777    }
778
779    // -- Assemble the `ProjectAnalysis`. Mirrored the now-deleted
780    //    `analyse_project_with`'s own `RunChecks::Checked` arm exactly. --
781    let index = assemble_index(
782        &parsed,
783        &unit_uses,
784        &unit_consumes,
785        std::mem::take(&mut refs),
786    );
787
788    let mut unit_sources: HashMap<String, Vec<PathBuf>> = HashMap::new();
789    for pf in &parsed {
790        if pf.is_synthetic() {
791            continue;
792        }
793        unit_sources
794            .entry(pf.unit().name().joined())
795            .or_default()
796            .push(pf.identity_path());
797    }
798
799    let mut sequence_info: HashMap<String, ContextSequenceInfo> = HashMap::new();
800    let mut boundary_info: HashMap<String, ContextBoundaryInfo> = HashMap::new();
801    for (name, kind) in &kinds {
802        if !matches!(kind, UnitKind::Context | UnitKind::Adapter) {
803            continue;
804        }
805        let Some(table) = unit_tables.get(name) else {
806            continue;
807        };
808        let mut cross_context = build_cross_context_info(
809            name,
810            &unit_consumes,
811            &unit_consumes_aliases,
812            &unit_uses,
813            &unit_tables,
814        );
815        cross_context.flattened_caps = unit_flattened.get(name).cloned().unwrap_or_default();
816        let agents: HashMap<String, AgentDecl> = table.agents.clone();
817        sequence_info.insert(
818            name.clone(),
819            ContextSequenceInfo {
820                cross_context,
821                agents: agents.clone(),
822            },
823        );
824        boundary_info.insert(
825            name.clone(),
826            ContextBoundaryInfo {
827                // #2073: the table `own_contract_hashes` hashes through.
828                types: crate::symbols::contract_types_for(
829                    name,
830                    &unit_tables,
831                    &unit_uses,
832                    &unit_consumes,
833                )
834                .0,
835                services: table.services.clone(),
836                agents,
837            },
838        );
839    }
840
841    let mut doc_scope: HashMap<String, Vec<String>> = HashMap::new();
842    for name in unit_sources.keys() {
843        let mut scope = vec![name.clone()];
844        scope.extend(unit_uses.get(name).cloned().unwrap_or_default());
845        scope.extend(unit_consumes.get(name).cloned().unwrap_or_default());
846        doc_scope.insert(name.clone(), scope);
847    }
848
849    ProjectAnalysis {
850        snapshots,
851        errors: errors.into_all(),
852        index,
853        hints: hints.take_files(),
854        locals: locals.take_files(),
855        expr_types: exprs.take_files(),
856        ty_intern: Arc::clone(tys),
857        requirements: requirements.take_files(),
858        unit_sources,
859        sequence_info,
860        boundary_info,
861        doc_scope,
862    }
863}
864
865#[cfg(test)]
866mod tests {
867    use crate::testkit::analyse;
868
869    const MONEY: &str = "commons demo.money\n\ntype Cents = Int where NonNegative\n";
870
871    const PAYMENT: &str = "context demo.payment\n\n\
872exports transparent { Receipt }\n\n\
873type Receipt = { id: String }\n\n\
874service authorise {\n  on call(id: String) -> Effect[Result[Receipt, Int]] {\n    Ok(Receipt { id: id })\n  }\n}\n";
875
876    const CHECKOUT: &str = "context demo.checkout\n\n\
877uses demo.money\n\nconsumes demo.payment\n\n\
878fn price() -> Cents { 100 }\n\n\
879service pay {\n  on call(id: String) -> Effect[Result[Receipt, Int]] {\n    demo.payment.authorise(id)\n  }\n}\n";
880
881    #[test]
882    fn a_consuming_context_checks_clean_against_its_uses_and_consumes() {
883        let a = analyse(&[
884            ("demo/money.bynk", MONEY),
885            ("demo/payment.bynk", PAYMENT),
886            ("demo/checkout.bynk", CHECKOUT),
887        ]);
888        a.assert_clean();
889        assert_eq!(
890            a.type_at("demo/checkout.bynk", "demo.payment.authorise(id)"),
891            "Effect[Result[Receipt, Int]]"
892        );
893    }
894
895    #[test]
896    fn doc_scope_is_the_unit_then_its_uses_then_its_consumes() {
897        let a = analyse(&[
898            ("demo/money.bynk", MONEY),
899            ("demo/payment.bynk", PAYMENT),
900            ("demo/checkout.bynk", CHECKOUT),
901        ]);
902        assert_eq!(
903            a.analysis.doc_scope["demo.checkout"],
904            ["demo.checkout", "demo.money", "demo.payment"]
905        );
906        assert_eq!(a.analysis.doc_scope["demo.money"], ["demo.money"]);
907    }
908
909    #[test]
910    fn unit_sources_name_each_unit_s_own_files_and_no_synthetic_unit() {
911        let a = analyse(&[("demo/money.bynk", MONEY), ("demo/payment.bynk", PAYMENT)]);
912        let mut units: Vec<&str> = a.analysis.unit_sources.keys().map(String::as_str).collect();
913        units.sort();
914        assert_eq!(units, ["demo.money", "demo.payment"]);
915        assert!(a.analysis.unit_sources["demo.money"][0].ends_with("demo/money.bynk"));
916    }
917
918    #[test]
919    fn sequence_and_boundary_info_cover_contexts_only() {
920        let a = analyse(&[
921            ("demo/money.bynk", MONEY),
922            ("demo/payment.bynk", PAYMENT),
923            ("demo/checkout.bynk", CHECKOUT),
924        ]);
925        for info in [
926            a.analysis.sequence_info.keys().collect::<Vec<_>>(),
927            a.analysis.boundary_info.keys().collect::<Vec<_>>(),
928        ] {
929            let mut units: Vec<&str> = info.into_iter().map(String::as_str).collect();
930            units.sort();
931            assert_eq!(units, ["demo.checkout", "demo.payment"]);
932        }
933    }
934
935    #[test]
936    fn a_broken_unit_does_not_stop_an_independent_unit_from_checking() {
937        let a = analyse(&[
938            ("demo/money.bynk", MONEY),
939            (
940                "demo/broken.bynk",
941                "commons demo.broken\n\nfn f() -> Int { \"not an int\" }\n",
942            ),
943            (
944                "demo/fine.bynk",
945                "commons demo.fine\n\nuses demo.money\n\nfn price() -> Cents { 100 }\n",
946            ),
947        ]);
948        assert!(
949            a.analysis.errors.iter().all(|e| e
950                .source_path
951                .as_deref()
952                .is_some_and(|p| p.ends_with("demo/broken.bynk"))),
953            "only the broken unit reports:\n{}",
954            a.render()
955        );
956        assert_eq!(a.type_at("demo/fine.bynk", "100"), "Cents");
957    }
958
959    #[test]
960    fn every_file_read_is_snapshotted_clean_or_not() {
961        let a = analyse(&[
962            ("demo/money.bynk", MONEY),
963            (
964                "demo/broken.bynk",
965                "commons demo.broken\n\nfn f() -> Int { \"x\" }\n",
966            ),
967        ]);
968        let mut paths: Vec<&std::path::Path> = a
969            .analysis
970            .snapshots
971            .iter()
972            .map(|(p, _)| p.as_path())
973            .collect();
974        paths.sort();
975        assert_eq!(paths.len(), 2, "{paths:?}");
976        assert!(paths[0].ends_with("demo/broken.bynk") && paths[1].ends_with("demo/money.bynk"));
977    }
978}