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::{
33 check_context_constraints, check_context_declarations, check_handler_constraints,
34};
35use crate::expr_types::ExprTypeSink;
36use crate::hints::HintSink;
37use crate::index::RefSink;
38use crate::locals::LocalsSink;
39use crate::project_model::{ErrorSink, UnitInfo};
40use crate::requirements::RequirementSink;
41use crate::resolver::{self, MethodTable as ResolverMethodTable, ResolvedCommons};
42use crate::symbols::{ConsumedType, UnitTable, build_cross_context_info, combined_types_for};
43use bynk_project::{ParsedFile, UnitKind};
44use bynk_syntax::ast::{CommonsItem, ExprId, FnName, TypeDecl};
45
46/// Record a file's (possibly partial) expression types into the Analyse-mode
47/// sink. Called at every per-file exit in the check loop so `.`-member
48/// completion and signature help get the receiver's type even when a later
49/// check phase errors for the file (ADR 0094). A no-op-shaped wrapper,
50/// factored out so the four error-path exits (now unconditional, see this
51/// module's own doc comment) and every clean-path caller share one call.
52pub fn record_analyse_types(
53 exprs: &mut ExprTypeSink,
54 source_path: &Path,
55 synthetic: bool,
56 types: &HashMap<ExprId, checker::TypedExpr>,
57) {
58 exprs.enter_file(source_path, synthetic);
59 exprs.record_file(types);
60}
61
62/// The four parallel per-project maps `build_cross_context_info`/
63/// `combined_types_for` need (their own general, map-based signature — see
64/// [`UnitCheckCtx`]'s own doc comment for why the per-file core materialises
65/// them from `unit_info` rather than changing that signature).
66type CrossContextViews = (
67 HashMap<String, UnitTable>,
68 HashMap<String, Vec<String>>,
69 HashMap<String, Vec<String>>,
70 HashMap<String, HashMap<String, String>>,
71);
72
73/// v0.29.4: `build_cross_context_info` (and its `combined_types_for` helper)
74/// is a general map-based function — the test-emission path calls it with
75/// *synthetic* harness maps, not `unit_info` — so it keeps its parallel-map
76/// signature. The per-file core only has `unit_info`, so this materialises
77/// the four views that one call needs, once per unit ahead of the file loop
78/// — but only for a context/adapter, `build_cross_context_info`'s only
79/// caller. `UnitTable` owns every declaration body in the unit, so for every
80/// other unit kind (including the seven injected first-party commons) this
81/// would otherwise be a whole-project deep clone, performed and discarded,
82/// once per unit.
83pub struct UnitCheckCtx {
84 cross_context_views: Option<CrossContextViews>,
85 /// #907: the exact set of type names `emit_context_rebrands` rebrands for
86 /// this unit — names brought in via `uses` of a *commons* specifically
87 /// (not a local declaration, and not a type surfaced via `consumes`,
88 /// which `imported_from_kind` tags `UnitKind::Context` in
89 /// `merge_consumed_exports` and which the emitter never rebrands).
90 pub uses_commons_type_names: HashSet<String>,
91 /// #1710: the declarations recovery skipped in the files this unit can see
92 /// (its own, and the units it `uses` and `consumes`). A reference to one is
93 /// a known name, not an unknown one (#1663's Decision B), so its
94 /// unknown-name echo is not reported. One level of `uses`, matching
95 /// `compose_unit_symbols`. A suite sees its target's skipped names because
96 /// it carries its target's unit name; it is in no unit's `uses`/`consumes`.
97 pub visible_broken_names: Vec<String>,
98}
99
100/// Build the per-unit prelude [`check_file_core`] shares across every file
101/// in the unit — see [`UnitCheckCtx`]'s own doc comment.
102pub fn prepare_unit_check_ctx(
103 name: &str,
104 kind: UnitKind,
105 broken: &crate::project_model::BrokenDeclNames,
106 unit_info: &BTreeMap<String, UnitInfo>,
107 combined_types: &HashMap<String, Arc<TypeDecl>>,
108 imported_from_kind: &HashMap<String, UnitKind>,
109) -> UnitCheckCtx {
110 let cross_context_views = if kind == UnitKind::Context || kind == UnitKind::Adapter {
111 let unit_tables: HashMap<String, UnitTable> = unit_info
112 .iter()
113 .map(|(n, i)| (n.clone(), i.table.clone()))
114 .collect();
115 let unit_uses: HashMap<String, Vec<String>> = unit_info
116 .iter()
117 .map(|(n, i)| (n.clone(), i.uses.clone()))
118 .collect();
119 let unit_consumes: HashMap<String, Vec<String>> = unit_info
120 .iter()
121 .map(|(n, i)| (n.clone(), i.consumes.clone()))
122 .collect();
123 let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = unit_info
124 .iter()
125 .map(|(n, i)| (n.clone(), i.aliases.clone()))
126 .collect();
127 Some((unit_tables, unit_uses, unit_consumes, unit_consumes_aliases))
128 } else {
129 None
130 };
131 let uses_commons_type_names: HashSet<String> = imported_from_kind
132 .keys()
133 .filter(|n| {
134 crate::resolver::compute_is_uses_commons_type(imported_from_kind, combined_types, n)
135 })
136 .cloned()
137 .collect();
138 let visible_broken_names: Vec<String> = std::iter::once(name)
139 .chain(
140 unit_info
141 .get(name)
142 .into_iter()
143 .flat_map(|i| i.uses.iter().chain(i.consumes.iter()).map(String::as_str)),
144 )
145 .filter_map(|u| broken.get(u))
146 .flatten()
147 .cloned()
148 .collect();
149 UnitCheckCtx {
150 cross_context_views,
151 uses_commons_type_names,
152 visible_broken_names,
153 }
154}
155
156/// The clean-path output of [`check_file_core`]: the typed, fully-checked
157/// unit plus the per-file cross-context info that produced it — a
158/// `Mode::Build` caller needs both to reach `certify`+`emit_unit` (`emit_unit`
159/// takes `cross_context_for_file` as its own argument, so this avoids making
160/// the caller recompute it from `ctx`/`unit_info` a second time).
161pub struct FileCheckResult {
162 pub typed: TypedCommons,
163 pub cross_context: resolver::CrossContextInfo,
164}
165
166/// The shared resolve+check+context-checks core for one file, factored out
167/// of `check_unit_files` (see this module's own doc comment). Returns
168/// `Some(FileCheckResult)` only on the fully-clean, non-blocked path — the
169/// signal a `Mode::Build` caller uses to know it may proceed to
170/// `certify`+`emit_unit`. Every error/blocked exit records best-effort
171/// partial types unconditionally (see [`record_analyse_types`]) and returns
172/// `None`.
173#[allow(clippy::too_many_arguments)]
174pub fn check_file_core(
175 name: &str,
176 kind: UnitKind,
177 pf: &ParsedFile,
178 unit_info: &BTreeMap<String, UnitInfo>,
179 combined_types: &HashMap<String, Arc<TypeDecl>>,
180 combined_fns: &HashMap<String, Arc<bynk_syntax::ast::FnDecl>>,
181 combined_methods: &HashMap<String, ResolverMethodTable>,
182 local_names: &HashSet<String>,
183 local_methods_for_type: &HashMap<String, Vec<bynk_syntax::ast::FnDecl>>,
184 consumed_types: &HashMap<String, ConsumedType>,
185 imported_from: &HashMap<String, String>,
186 ctx: &UnitCheckCtx,
187 errors: &mut ErrorSink,
188 refs: &mut RefSink,
189 hints: &mut HintSink,
190 locals: &mut LocalsSink,
191 exprs: &mut ExprTypeSink,
192 requirements: &mut RequirementSink,
193 tys: &Arc<Types>,
194) -> Option<FileCheckResult> {
195 let mut emit_items: Vec<CommonsItem> = Vec::new();
196 let types_in_this_file: HashSet<String> = pf
197 .items()
198 .iter()
199 .filter_map(|it| match it {
200 CommonsItem::Type(t) => Some(t.name.name.clone()),
201 // Events track, slice 0 (spine #936): an `event` shares the
202 // `types` namespace, so a multi-file context's method dispatch
203 // treats its name the same as a `type`'s.
204 CommonsItem::Event(e) => Some(e.name.name.clone()),
205 _ => None,
206 })
207 .collect();
208 for item in pf.items() {
209 match item {
210 CommonsItem::Type(t) => {
211 emit_items.push(CommonsItem::Type(t.clone()));
212 }
213 CommonsItem::Fn(f) => match &f.name {
214 FnName::Free(_) => emit_items.push(CommonsItem::Fn(f.clone())),
215 FnName::Method { type_name, .. } => {
216 if types_in_this_file.contains(&type_name.name) {
217 emit_items.push(CommonsItem::Fn(f.clone()));
218 }
219 }
220 },
221 CommonsItem::Capability(c) => {
222 emit_items.push(CommonsItem::Capability(c.clone()));
223 }
224 CommonsItem::Provider(p) => {
225 emit_items.push(CommonsItem::Provider(p.clone()));
226 }
227 CommonsItem::Service(s) => {
228 emit_items.push(CommonsItem::Service(s.clone()));
229 }
230 CommonsItem::Agent(a) => {
231 emit_items.push(CommonsItem::Agent(a.clone()));
232 }
233 CommonsItem::Actor(a) => {
234 // Actors emit no standalone TS, but are carried so the
235 // emitter can read their schemes for the verification seam.
236 emit_items.push(CommonsItem::Actor(a.clone()));
237 }
238 CommonsItem::Messages(m) => {
239 emit_items.push(CommonsItem::Messages(m.clone()));
240 }
241 CommonsItem::Event(e) => {
242 emit_items.push(CommonsItem::Event(e.clone()));
243 }
244 }
245 }
246 for type_name in &types_in_this_file {
247 if let Some(methods) = local_methods_for_type.get(type_name) {
248 for m in methods {
249 let already = emit_items.iter().any(|it| match it {
250 CommonsItem::Fn(existing) => match &existing.name {
251 FnName::Method {
252 type_name: t,
253 method_name: n,
254 } => match &m.name {
255 FnName::Method {
256 type_name: t2,
257 method_name: n2,
258 } => t.name == t2.name && n.name == n2.name,
259 _ => false,
260 },
261 _ => false,
262 },
263 _ => false,
264 });
265 if !already {
266 emit_items.push(CommonsItem::Fn(m.clone()));
267 }
268 }
269 }
270 }
271
272 // Synthesize a "Commons-shaped" view of this file's items so we can
273 // drive the existing resolver/checker without duplication.
274 let synthetic_commons = pf.as_synthetic_commons(emit_items);
275
276 // Cross-context info (v0.6) for contexts: consumed contexts, aliases,
277 // services, and types. Computed once below; reused for the resolver,
278 // checker, and (in `bynk-emit`) the emitter. v0.18: adapters get it too,
279 // so an external provider's `given` resolves against the adapter's
280 // flattened consumed capabilities (spec §4.5).
281 let cross_context_for_file =
282 if let Some((unit_tables, unit_uses, unit_consumes, unit_consumes_aliases)) =
283 &ctx.cross_context_views
284 {
285 let mut cci = build_cross_context_info(
286 name,
287 unit_consumes,
288 unit_consumes_aliases,
289 unit_uses,
290 unit_tables,
291 );
292 cci.flattened_caps = unit_info[name].flattened.clone();
293 cci
294 } else {
295 resolver::CrossContextInfo::default()
296 };
297
298 // Events slice 3a (#972): this unit's own local + direct-`uses` types
299 // (deliberately narrower than `combined_types`, which also merges
300 // `consumes`) — the same view `emit_consumed_context_helpers` (#973)
301 // builds for a *subscriber* regenerating this unit's own event codecs
302 // cross-context. `check_context_declarations` uses it to validate an
303 // event field default is constructible in that narrower view, not just
304 // this unit's own wider one.
305 let subscriber_visible_types: HashMap<String, Arc<TypeDecl>> =
306 if let Some((unit_tables, unit_uses, _, _)) = &ctx.cross_context_views {
307 combined_types_for(name, unit_tables, unit_uses)
308 } else {
309 HashMap::new()
310 };
311
312 // `ResolvedCommons::new` derives `local_type_names`/`event_type_names`
313 // from this unit's own pre-merge table (`unit_info[name].table`), not
314 // `combined_types` (already local+uses+consumes merged) — same
315 // distinction the caller's `local_names` exists for. `Events.emit[E]`
316 // additionally needs "is this specifically an event" on top of
317 // owner-only emission (an ordinary local type must not pass as an emit
318 // target just because it's locally declared), hence the separate
319 // `events` table. Both are empty for a unit absent from `unit_info`.
320 let empty_types = HashMap::new();
321 let empty_events = HashMap::new();
322 let local_table = unit_info.get(name).map(|i| &i.table);
323 let local_types = local_table.map(|t| &t.types).unwrap_or(&empty_types);
324 let local_events = local_table.map(|t| &t.events).unwrap_or(&empty_events);
325
326 let resolved = ResolvedCommons::new(
327 synthetic_commons,
328 combined_types.clone(),
329 local_types,
330 combined_fns.clone(),
331 combined_methods.clone(),
332 HashMap::new(),
333 local_events,
334 cross_context_for_file.clone(),
335 // ADR 0116 D6: provenance for the `bynk.list` deprecation lint.
336 imported_from.clone(),
337 kind == UnitKind::Context,
338 ctx.uses_commons_type_names.clone(),
339 );
340 refs.enter_file(&pf.identity_path(), name, pf.is_synthetic());
341 // v0.27: synthetic and test/integration files record no hints — neither
342 // surfaces in an editor (the `assemble_index` rule).
343 hints.enter_file(
344 &pf.identity_path(),
345 pf.is_synthetic() || matches!(pf.kind(), UnitKind::Test | UnitKind::Integration),
346 );
347 // v0.31: locals serve completion/navigation in test files too — only
348 // synthetic (toolchain-injected) files are muted.
349 locals.enter_file(&pf.identity_path(), pf.is_synthetic());
350 // v0.99: capability requirements follow the inlay-hint muting rule —
351 // synthetic and test/integration files surface none in an editor.
352 requirements.enter_file(
353 &pf.identity_path(),
354 pf.is_synthetic() || matches!(pf.kind(), UnitKind::Test | UnitKind::Integration),
355 );
356 // #1663 (Decision A): a resolve error no longer stops the file before the
357 // checker. Every declaration is still checked; the checker's diagnostics
358 // in a declaration the resolver rejected are its echoes and are dropped
359 // (`without_resolve_echoes`), and the file still fails here.
360 let resolve_errors = resolver::resolve_file_record(&resolved, refs).err();
361 let item_spans: Vec<bynk_syntax::span::Span> =
362 resolved.commons.items.iter().map(|i| i.span()).collect();
363 // The unit's own span is this file's, even when it has no items of its own
364 // (only methods on a sibling file's type, or only `uses`).
365 let own_file = resolved.commons.span.file;
366 let rc = checker::check_record_in(resolved, tys, refs, hints, locals, requirements);
367 if let Some(resolve_errors) = &resolve_errors {
368 // #1710: a reference to a declaration recovery skipped (here or in a
369 // file this unit can see) is a known name; its echo isn't reported. It
370 // still counts as a resolve error below (Decision A).
371 let (shown, _hidden) =
372 resolver::split_broken_decl_echoes(resolve_errors.clone(), &ctx.visible_broken_names);
373 errors.extend_for(Some(&pf.identity_path()), shown);
374 }
375 // Every diagnostic past the resolver goes out through this: the checker's
376 // errors and warnings, and the declaration stages' (`ours`, below).
377 // Decision A drops the checker's follow-ons inside a declaration the
378 // resolver rejected; #1710 then drops echoes of a declaration recovery
379 // skipped (a method, a capability, an actor, a consumed context's service)
380 // that this unit can see. The checker reports those under its own codes,
381 // in any unit kind, so the split runs here, not only in the stages.
382 let unechoed = |errs: Vec<bynk_syntax::CompileError>| {
383 let errs = match &resolve_errors {
384 Some(r) => resolver::without_resolve_echoes(errs, r, &item_spans),
385 None => errs,
386 };
387 resolver::split_broken_decl_echoes(errs, &ctx.visible_broken_names).0
388 };
389 // #1663: the declaration stages walk the whole unit's handlers, so a file's
390 // pass also meets another file's faults — and attributes them to this
391 // file, at a position in the wrong source. That file's own pass reports
392 // them; here, keep only this file's diagnostics (or unlocated ones), and
393 // decide whether a stage failed this file from those alone.
394 let ours = |errs: Vec<bynk_syntax::CompileError>| -> Vec<bynk_syntax::CompileError> {
395 unechoed(
396 errs.into_iter()
397 .filter(|e| e.span.file == own_file || e.span == bynk_syntax::span::Span::default())
398 .collect(),
399 )
400 };
401 // #1663: whether this file has already failed (a resolve or type error).
402 // The later stages still check its other declarations; the file returns
403 // no result at the end.
404 let mut failed = resolve_errors.is_some();
405 let typed = match rc.result {
406 Ok(t) => {
407 // v0.89 (ADR 0117): a unit that checks clean may still carry
408 // non-failing warnings — push them into the (severity-aware)
409 // sink, where they are classified as warnings and never gate.
410 if !t.warnings.is_empty() {
411 errors.extend_for(Some(&pf.identity_path()), unechoed(t.warnings.clone()));
412 }
413 t
414 }
415 Err(errs) => {
416 errors.extend_for(Some(&pf.identity_path()), unechoed(errs));
417 // ADR 0094: surface the best-effort partial types the checker
418 // computed so `.`-member completion / signature help work on a
419 // buffer with an unrelated error. Unconditional now (this
420 // module's own doc comment) — a `Mode::Build` caller simply
421 // never reads the sink this lands in.
422 record_analyse_types(
423 exprs,
424 &pf.identity_path(),
425 pf.is_synthetic(),
426 &rc.partial_expr_types,
427 );
428 // #1663 (Decision A): a type error in one declaration must not
429 // hide the next stage's checks of the others (a service handler's
430 // body is typed in `check_context_declarations`).
431 failed = true;
432 rc.typed_despite_errors?
433 }
434 };
435
436 // Run the context-specific checks: forbidden construction, private-type
437 // references.
438 if kind == UnitKind::Context {
439 let context_check_errs = ours(check_context_constraints(
440 &typed,
441 consumed_types,
442 local_names,
443 tys,
444 ));
445 if !context_check_errs.is_empty() {
446 errors.extend_for(Some(&pf.identity_path()), context_check_errs);
447 record_analyse_types(
448 exprs,
449 &pf.identity_path(),
450 pf.is_synthetic(),
451 &typed.expr_types,
452 );
453 return None;
454 }
455 }
456
457 // v0.5: check capability/provider/service/agent declarations. v0.18:
458 // adapters run these too — an external provider's `given` resolves
459 // through the same path as a bodied provider's (the service/agent
460 // checks are vacuous for adapters, which have none).
461 let mut typed = typed;
462 let unit_table_owned = unit_info.get(name).map(|i| i.table.clone());
463 if (kind == UnitKind::Context || kind == UnitKind::Adapter)
464 && let Some(table) = unit_table_owned.as_ref()
465 {
466 let decl_errs = ours(check_context_declarations(
467 &mut typed,
468 table,
469 &cross_context_for_file,
470 kind == UnitKind::Context,
471 &ctx.uses_commons_type_names,
472 &subscriber_visible_types,
473 refs,
474 hints,
475 locals,
476 requirements,
477 tys,
478 ));
479 if !decl_errs.is_empty() {
480 // ADR 0117: a warning-severity declaration diagnostic (e.g. the
481 // `@indexed` hygiene hints) must not block emission — only an
482 // error does. Partition first, then gate on error severity
483 // alone.
484 let blocks_emission = decl_errs.iter().any(|e| {
485 matches!(
486 bynk_syntax::Severity::for_error(e),
487 bynk_syntax::Severity::Error
488 )
489 });
490 errors.extend_for(Some(&pf.identity_path()), decl_errs);
491 if blocks_emission {
492 // ADR 0094: handler bodies are typed here — surface their
493 // best-effort types even when a declaration check (e.g. a
494 // service/agent wiring error) fails for the file.
495 record_analyse_types(
496 exprs,
497 &pf.identity_path(),
498 pf.is_synthetic(),
499 &typed.expr_types,
500 );
501 return None;
502 }
503 // Warnings only: the declarations are valid — fall through.
504 }
505 }
506
507 // #1700: the context constraints again, over the handler and provider
508 // bodies `check_context_declarations` has just typed.
509 if kind == UnitKind::Context {
510 let handler_errs = ours(check_handler_constraints(
511 &typed,
512 consumed_types,
513 local_names,
514 tys,
515 ));
516 if !handler_errs.is_empty() {
517 errors.extend_for(Some(&pf.identity_path()), handler_errs);
518 record_analyse_types(
519 exprs,
520 &pf.identity_path(),
521 pf.is_synthetic(),
522 &typed.expr_types,
523 );
524 return None;
525 }
526 }
527
528 // #1663: every stage has now checked past the earlier errors; the file
529 // still fails on them.
530 if failed {
531 record_analyse_types(
532 exprs,
533 &pf.identity_path(),
534 pf.is_synthetic(),
535 &typed.expr_types,
536 );
537 return None;
538 }
539 Some(FileCheckResult {
540 typed,
541 cross_context: cross_context_for_file,
542 })
543}