bynk_check/check_pipeline.rs
1//! The shared per-unit/per-file resolve-check core, used by both
2//! `bynk-emit`'s `check_unit_files` (`Mode::Build` and `Mode::Analyse`) and
3//! this crate's own [`crate::analysis::analyse_project`].
4//!
5//! P4.1 (#1115), the same `extract, don't duplicate` move as
6//! [`crate::project_model`]: `check_unit_files`'s per-file body is identical
7//! for both modes except for four `record_analyse_types` call sites (the
8//! error-path exits) and the final "Analyse mode always stops here, Build
9//! mode falls through to `certify`+`emit_unit`" branch. This module owns
10//! everything up to (not including) that branch — [`check_file_core`]
11//! returns `Some(TypedCommons)` only on the fully-clean, non-blocked path, so
12//! a caller that wants to emit knows exactly when it may. The four
13//! error-path recordings are unconditional here now (previously gated on
14//! `mode == Mode::Analyse`) — behaviour-preserving for the `Mode::Build`
15//! caller, which never took that branch anyway (`mode == Mode::Analyse`
16//! gated it), and whose `exprs` sink `compile_project`'s `ProjectOutput`
17//! never exposes.
18//!
19//! What stayed in `bynk-emit`: `Mode` itself (meaningless here — this
20//! crate's own entry point has exactly one behaviour), `certify`+`emit_unit`
21//! (real emission), and the decision of *whether* to record the clean-path
22//! types (each caller does that itself with the `Some(TypedCommons)` this
23//! module hands back — `bynk-emit`'s `Mode::Build` caller skips it,
24//! `Mode::Analyse` and this crate's own entry point both call
25//! [`record_analyse_types`]).
26
27use std::collections::{BTreeMap, HashMap, HashSet};
28use std::path::Path;
29use std::sync::Arc;
30
31use crate::checker::{self, TypedCommons, Types};
32use crate::context_checks::{check_context_constraints, check_context_declarations};
33use crate::expr_types::ExprTypeSink;
34use crate::hints::HintSink;
35use crate::index::RefSink;
36use crate::locals::LocalsSink;
37use crate::project_model::{ErrorSink, UnitInfo};
38use crate::requirements::RequirementSink;
39use crate::resolver::{self, MethodTable as ResolverMethodTable, ResolvedCommons};
40use crate::symbols::{ConsumedType, UnitTable, build_cross_context_info, combined_types_for};
41use bynk_project::{ParsedFile, UnitKind};
42use bynk_syntax::ast::{CommonsItem, ExprId, FnName, TypeDecl};
43
44/// Record a file's (possibly partial) expression types into the Analyse-mode
45/// sink. Called at every per-file exit in the check loop so `.`-member
46/// completion and signature help get the receiver's type even when a later
47/// check phase errors for the file (ADR 0094). A no-op-shaped wrapper,
48/// factored out so the four error-path exits (now unconditional, see this
49/// module's own doc comment) and every clean-path caller share one call.
50pub fn record_analyse_types(
51 exprs: &mut ExprTypeSink,
52 source_path: &Path,
53 synthetic: bool,
54 types: &HashMap<ExprId, checker::TypedExpr>,
55) {
56 exprs.enter_file(source_path, synthetic);
57 exprs.record_file(types);
58}
59
60/// The four parallel per-project maps `build_cross_context_info`/
61/// `combined_types_for` need (their own general, map-based signature — see
62/// [`UnitCheckCtx`]'s own doc comment for why the per-file core materialises
63/// them from `unit_info` rather than changing that signature).
64type CrossContextViews = (
65 HashMap<String, UnitTable>,
66 HashMap<String, Vec<String>>,
67 HashMap<String, Vec<String>>,
68 HashMap<String, HashMap<String, String>>,
69);
70
71/// v0.29.4: `build_cross_context_info` (and its `combined_types_for` helper)
72/// is a general map-based function — the test-emission path calls it with
73/// *synthetic* harness maps, not `unit_info` — so it keeps its parallel-map
74/// signature. The per-file core only has `unit_info`, so this materialises
75/// the four views that one call needs, once per unit ahead of the file loop
76/// — but only for a context/adapter, `build_cross_context_info`'s only
77/// caller. `UnitTable` owns every declaration body in the unit, so for every
78/// other unit kind (including the seven injected first-party commons) this
79/// would otherwise be a whole-project deep clone, performed and discarded,
80/// once per unit.
81pub struct UnitCheckCtx {
82 cross_context_views: Option<CrossContextViews>,
83 /// #907: the exact set of type names `emit_context_rebrands` rebrands for
84 /// this unit — names brought in via `uses` of a *commons* specifically
85 /// (not a local declaration, and not a type surfaced via `consumes`,
86 /// which `imported_from_kind` tags `UnitKind::Context` in
87 /// `merge_consumed_exports` and which the emitter never rebrands).
88 pub uses_commons_type_names: HashSet<String>,
89}
90
91/// Build the per-unit prelude [`check_file_core`] shares across every file
92/// in the unit — see [`UnitCheckCtx`]'s own doc comment.
93pub fn prepare_unit_check_ctx(
94 kind: UnitKind,
95 unit_info: &BTreeMap<String, UnitInfo>,
96 combined_types: &HashMap<String, Arc<TypeDecl>>,
97 imported_from_kind: &HashMap<String, UnitKind>,
98) -> UnitCheckCtx {
99 let cross_context_views = if kind == UnitKind::Context || kind == UnitKind::Adapter {
100 let unit_tables: HashMap<String, UnitTable> = unit_info
101 .iter()
102 .map(|(n, i)| (n.clone(), i.table.clone()))
103 .collect();
104 let unit_uses: HashMap<String, Vec<String>> = unit_info
105 .iter()
106 .map(|(n, i)| (n.clone(), i.uses.clone()))
107 .collect();
108 let unit_consumes: HashMap<String, Vec<String>> = unit_info
109 .iter()
110 .map(|(n, i)| (n.clone(), i.consumes.clone()))
111 .collect();
112 let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = unit_info
113 .iter()
114 .map(|(n, i)| (n.clone(), i.aliases.clone()))
115 .collect();
116 Some((unit_tables, unit_uses, unit_consumes, unit_consumes_aliases))
117 } else {
118 None
119 };
120 let uses_commons_type_names: HashSet<String> = imported_from_kind
121 .iter()
122 .filter(|(n, k)| **k == UnitKind::Commons && combined_types.contains_key(n.as_str()))
123 .map(|(n, _)| n.clone())
124 .collect();
125 UnitCheckCtx {
126 cross_context_views,
127 uses_commons_type_names,
128 }
129}
130
131/// The clean-path output of [`check_file_core`]: the typed, fully-checked
132/// unit plus the per-file cross-context info that produced it — a
133/// `Mode::Build` caller needs both to reach `certify`+`emit_unit` (`emit_unit`
134/// takes `cross_context_for_file` as its own argument, so this avoids making
135/// the caller recompute it from `ctx`/`unit_info` a second time).
136pub struct FileCheckResult {
137 pub typed: TypedCommons,
138 pub cross_context: resolver::CrossContextInfo,
139}
140
141/// The shared resolve+check+context-checks core for one file, factored out
142/// of `check_unit_files` (see this module's own doc comment). Returns
143/// `Some(FileCheckResult)` only on the fully-clean, non-blocked path — the
144/// signal a `Mode::Build` caller uses to know it may proceed to
145/// `certify`+`emit_unit`. Every error/blocked exit records best-effort
146/// partial types unconditionally (see [`record_analyse_types`]) and returns
147/// `None`.
148#[allow(clippy::too_many_arguments)]
149pub fn check_file_core(
150 name: &str,
151 kind: UnitKind,
152 pf: &ParsedFile,
153 unit_info: &BTreeMap<String, UnitInfo>,
154 combined_types: &HashMap<String, Arc<TypeDecl>>,
155 combined_fns: &HashMap<String, Arc<bynk_syntax::ast::FnDecl>>,
156 combined_methods: &HashMap<String, ResolverMethodTable>,
157 local_names: &HashSet<String>,
158 local_methods_for_type: &HashMap<String, Vec<bynk_syntax::ast::FnDecl>>,
159 consumed_types: &HashMap<String, ConsumedType>,
160 imported_from: &HashMap<String, String>,
161 ctx: &UnitCheckCtx,
162 errors: &mut ErrorSink,
163 refs: &mut RefSink,
164 hints: &mut HintSink,
165 locals: &mut LocalsSink,
166 exprs: &mut ExprTypeSink,
167 requirements: &mut RequirementSink,
168 tys: &Arc<Types>,
169) -> Option<FileCheckResult> {
170 let mut emit_items: Vec<CommonsItem> = Vec::new();
171 let types_in_this_file: HashSet<String> = pf
172 .items()
173 .iter()
174 .filter_map(|it| match it {
175 CommonsItem::Type(t) => Some(t.name.name.clone()),
176 // Events track, slice 0 (spine #936): an `event` shares the
177 // `types` namespace, so a multi-file context's method dispatch
178 // treats its name the same as a `type`'s.
179 CommonsItem::Event(e) => Some(e.name.name.clone()),
180 _ => None,
181 })
182 .collect();
183 for item in pf.items() {
184 match item {
185 CommonsItem::Type(t) => {
186 emit_items.push(CommonsItem::Type(t.clone()));
187 }
188 CommonsItem::Fn(f) => match &f.name {
189 FnName::Free(_) => emit_items.push(CommonsItem::Fn(f.clone())),
190 FnName::Method { type_name, .. } => {
191 if types_in_this_file.contains(&type_name.name) {
192 emit_items.push(CommonsItem::Fn(f.clone()));
193 }
194 }
195 },
196 CommonsItem::Capability(c) => {
197 emit_items.push(CommonsItem::Capability(c.clone()));
198 }
199 CommonsItem::Provider(p) => {
200 emit_items.push(CommonsItem::Provider(p.clone()));
201 }
202 CommonsItem::Service(s) => {
203 emit_items.push(CommonsItem::Service(s.clone()));
204 }
205 CommonsItem::Agent(a) => {
206 emit_items.push(CommonsItem::Agent(a.clone()));
207 }
208 CommonsItem::Actor(a) => {
209 // Actors emit no standalone TS, but are carried so the
210 // emitter can read their schemes for the verification seam.
211 emit_items.push(CommonsItem::Actor(a.clone()));
212 }
213 CommonsItem::Messages(m) => {
214 emit_items.push(CommonsItem::Messages(m.clone()));
215 }
216 CommonsItem::Event(e) => {
217 emit_items.push(CommonsItem::Event(e.clone()));
218 }
219 }
220 }
221 for type_name in &types_in_this_file {
222 if let Some(methods) = local_methods_for_type.get(type_name) {
223 for m in methods {
224 let already = emit_items.iter().any(|it| match it {
225 CommonsItem::Fn(existing) => match &existing.name {
226 FnName::Method {
227 type_name: t,
228 method_name: n,
229 } => match &m.name {
230 FnName::Method {
231 type_name: t2,
232 method_name: n2,
233 } => t.name == t2.name && n.name == n2.name,
234 _ => false,
235 },
236 _ => false,
237 },
238 _ => false,
239 });
240 if !already {
241 emit_items.push(CommonsItem::Fn(m.clone()));
242 }
243 }
244 }
245 }
246
247 // Synthesize a "Commons-shaped" view of this file's items so we can
248 // drive the existing resolver/checker without duplication.
249 let synthetic_commons = pf.as_synthetic_commons(emit_items);
250
251 // Cross-context info (v0.6) for contexts: consumed contexts, aliases,
252 // services, and types. Computed once below; reused for the resolver,
253 // checker, and (in `bynk-emit`) the emitter. v0.18: adapters get it too,
254 // so an external provider's `given` resolves against the adapter's
255 // flattened consumed capabilities (spec §4.5).
256 let cross_context_for_file =
257 if let Some((unit_tables, unit_uses, unit_consumes, unit_consumes_aliases)) =
258 &ctx.cross_context_views
259 {
260 let mut cci = build_cross_context_info(
261 name,
262 unit_consumes,
263 unit_consumes_aliases,
264 unit_uses,
265 unit_tables,
266 );
267 cci.flattened_caps = unit_info[name].flattened.clone();
268 cci
269 } else {
270 resolver::CrossContextInfo::default()
271 };
272
273 // Events slice 3a (#972): this unit's own local + direct-`uses` types
274 // (deliberately narrower than `combined_types`, which also merges
275 // `consumes`) — the same view `emit_consumed_context_helpers` (#973)
276 // builds for a *subscriber* regenerating this unit's own event codecs
277 // cross-context. `check_context_declarations` uses it to validate an
278 // event field default is constructible in that narrower view, not just
279 // this unit's own wider one.
280 let subscriber_visible_types: HashMap<String, Arc<TypeDecl>> =
281 if let Some((unit_tables, unit_uses, _, _)) = &ctx.cross_context_views {
282 combined_types_for(name, unit_tables, unit_uses)
283 } else {
284 HashMap::new()
285 };
286
287 // `ResolvedCommons::new` derives `local_type_names`/`event_type_names`
288 // from this unit's own pre-merge table (`unit_info[name].table`), not
289 // `combined_types` (already local+uses+consumes merged) — same
290 // distinction the caller's `local_names` exists for. `Events.emit[E]`
291 // additionally needs "is this specifically an event" on top of
292 // owner-only emission (an ordinary local type must not pass as an emit
293 // target just because it's locally declared), hence the separate
294 // `events` table. Both are empty for a unit absent from `unit_info`.
295 let empty_types = HashMap::new();
296 let empty_events = HashMap::new();
297 let local_table = unit_info.get(name).map(|i| &i.table);
298 let local_types = local_table.map(|t| &t.types).unwrap_or(&empty_types);
299 let local_events = local_table.map(|t| &t.events).unwrap_or(&empty_events);
300
301 let resolved = ResolvedCommons::new(
302 synthetic_commons,
303 combined_types.clone(),
304 local_types,
305 combined_fns.clone(),
306 combined_methods.clone(),
307 HashMap::new(),
308 local_events,
309 cross_context_for_file.clone(),
310 // ADR 0116 D6: provenance for the `bynk.list` deprecation lint.
311 imported_from.clone(),
312 kind == UnitKind::Context,
313 ctx.uses_commons_type_names.clone(),
314 );
315 refs.enter_file(&pf.identity_path(), name, pf.is_synthetic());
316 // v0.27: synthetic and test/integration files record no hints — neither
317 // surfaces in an editor (the `assemble_index` rule).
318 hints.enter_file(
319 &pf.identity_path(),
320 pf.is_synthetic() || matches!(pf.kind(), UnitKind::Test | UnitKind::Integration),
321 );
322 // v0.31: locals serve completion/navigation in test files too — only
323 // synthetic (toolchain-injected) files are muted.
324 locals.enter_file(&pf.identity_path(), pf.is_synthetic());
325 // v0.99: capability requirements follow the inlay-hint muting rule —
326 // synthetic and test/integration files surface none in an editor.
327 requirements.enter_file(
328 &pf.identity_path(),
329 pf.is_synthetic() || matches!(pf.kind(), UnitKind::Test | UnitKind::Integration),
330 );
331 if let Err(errs) = resolver::resolve_file_record(&resolved, refs) {
332 errors.extend_for(Some(&pf.identity_path()), errs);
333 return None;
334 }
335 let rc = checker::check_record_in(resolved, tys, refs, hints, locals, requirements);
336 let typed = match rc.result {
337 Ok(t) => {
338 // v0.89 (ADR 0117): a unit that checks clean may still carry
339 // non-failing warnings — push them into the (severity-aware)
340 // sink, where they are classified as warnings and never gate.
341 if !t.warnings.is_empty() {
342 errors.extend_for(Some(&pf.identity_path()), t.warnings.clone());
343 }
344 t
345 }
346 Err(errs) => {
347 errors.extend_for(Some(&pf.identity_path()), errs);
348 // ADR 0094: surface the best-effort partial types the checker
349 // computed so `.`-member completion / signature help work on a
350 // buffer with an unrelated error. Unconditional now (this
351 // module's own doc comment) — a `Mode::Build` caller simply
352 // never reads the sink this lands in.
353 record_analyse_types(
354 exprs,
355 &pf.identity_path(),
356 pf.is_synthetic(),
357 &rc.partial_expr_types,
358 );
359 return None;
360 }
361 };
362
363 // Run the context-specific checks: forbidden construction, private-type
364 // references.
365 if kind == UnitKind::Context {
366 let context_check_errs =
367 check_context_constraints(&typed, consumed_types, local_names, tys);
368 if !context_check_errs.is_empty() {
369 errors.extend_for(Some(&pf.identity_path()), context_check_errs);
370 record_analyse_types(
371 exprs,
372 &pf.identity_path(),
373 pf.is_synthetic(),
374 &typed.expr_types,
375 );
376 return None;
377 }
378 }
379
380 // v0.5: check capability/provider/service/agent declarations. v0.18:
381 // adapters run these too — an external provider's `given` resolves
382 // through the same path as a bodied provider's (the service/agent
383 // checks are vacuous for adapters, which have none).
384 let mut typed = typed;
385 let unit_table_owned = unit_info.get(name).map(|i| i.table.clone());
386 if (kind == UnitKind::Context || kind == UnitKind::Adapter)
387 && let Some(table) = unit_table_owned.as_ref()
388 {
389 let decl_errs = check_context_declarations(
390 &mut typed,
391 table,
392 &cross_context_for_file,
393 kind == UnitKind::Context,
394 &ctx.uses_commons_type_names,
395 &subscriber_visible_types,
396 refs,
397 hints,
398 locals,
399 requirements,
400 tys,
401 );
402 if !decl_errs.is_empty() {
403 // ADR 0117: a warning-severity declaration diagnostic (e.g. the
404 // `@indexed` hygiene hints) must not block emission — only an
405 // error does. Partition first, then gate on error severity
406 // alone.
407 let blocks_emission = decl_errs.iter().any(|e| {
408 matches!(
409 bynk_syntax::Severity::for_error(e),
410 bynk_syntax::Severity::Error
411 )
412 });
413 errors.extend_for(Some(&pf.identity_path()), decl_errs);
414 if blocks_emission {
415 // ADR 0094: handler bodies are typed here — surface their
416 // best-effort types even when a declaration check (e.g. a
417 // service/agent wiring error) fails for the file.
418 record_analyse_types(
419 exprs,
420 &pf.identity_path(),
421 pf.is_synthetic(),
422 &typed.expr_types,
423 );
424 return None;
425 }
426 // Warnings only: the declarations are valid — fall through.
427 }
428 }
429
430 Some(FileCheckResult {
431 typed,
432 cross_context: cross_context_for_file,
433 })
434}