1use std::collections::{BTreeMap, HashMap, HashSet};
57use std::path::PathBuf;
58use std::sync::Arc;
59
60use crate::checker::{self, Types};
61use crate::context_checks::{build_capability_op_info, ts_type_ref_display};
62use crate::hints::HintSink;
63use crate::index::{RefSink, SymbolKind};
64use crate::locals::LocalsSink;
65use crate::requirements::RequirementSink;
66use crate::resolver::{self, MethodTable as ResolverMethodTable, ResolvedCommons};
67use crate::symbols::{UnitTable, build_cross_context_info};
68use bynk_project::ParsedFile;
69use bynk_project::UnitKind;
70use bynk_project::discovery::{case_effective_tier, suite_effective_tier_is_system};
71use bynk_syntax::ast::*;
72use bynk_syntax::error::CompileError;
73use bynk_syntax::span::Span;
74
75#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum StubScope {
81 Every,
83 Case(String),
86 Suite(usize),
90}
91
92pub fn suite_ordinal(i: usize, indices: &[usize], parsed: &[ParsedFile]) -> Option<usize> {
97 indices
98 .iter()
99 .filter(|&&j| parsed[j].test().is_some())
100 .position(|&j| j == i)
101}
102
103#[derive(Debug, Clone)]
109pub struct ResolvedStub {
110 pub cap: String,
112 pub cap_decl: CapabilityDecl,
114 pub clauses: Vec<StubClause>,
117 pub clause_scopes: Vec<StubScope>,
119 pub identity_path: PathBuf,
127}
128
129#[allow(clippy::too_many_arguments)]
144pub fn phase_test_bodies(
145 test_groups: &BTreeMap<String, Vec<usize>>,
146 parsed: &[ParsedFile],
147 kinds: &BTreeMap<String, UnitKind>,
148 unit_tables: &HashMap<String, UnitTable>,
149 exports_visibility: &HashMap<String, HashMap<String, Visibility>>,
150 unit_consumes: &HashMap<String, Vec<String>>,
151 unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
152 unit_uses: &HashMap<String, Vec<String>>,
153 errors: &mut Vec<CompileError>,
154 refs: &mut RefSink,
155 tys: &Arc<Types>,
156) -> HashMap<String, HashMap<String, ResolvedStub>> {
157 let mut ready: HashMap<String, HashMap<String, ResolvedStub>> = HashMap::new();
158
159 let mut sorted_targets: Vec<&String> = test_groups.keys().collect();
160 sorted_targets.sort();
161
162 for target_name in sorted_targets {
163 let indices = test_groups.get(target_name).unwrap();
164 let target_kind = match kinds.get(target_name) {
166 Some(k) => *k,
167 None => {
168 let span = first_test_target_span(indices, parsed);
169 errors.push(
170 CompileError::new(
171 "bynk.suite.unknown_target",
172 span,
173 format!(
174 "test target `{target_name}` is not a declared commons or context in this project",
175 ),
176 )
177 .with_note(
178 "the target of a `test` declaration must be a commons or context declared elsewhere in the project",
179 ),
180 );
181 continue;
182 }
183 };
184
185 let mut seen_cases: HashMap<String, Span> = HashMap::new();
187 let mut had_dup = false;
188 for &i in indices {
189 if let Some(t) = parsed[i].test() {
190 for case in &t.cases {
191 if let Some(prev) = seen_cases.get(&case.name) {
192 had_dup = true;
193 errors.push(
194 CompileError::new(
195 "bynk.suite.duplicate_case_name",
196 case.name_span,
197 format!(
198 "test case `\"{}\"` is declared more than once in tests targeting `{target_name}`",
199 case.name
200 ),
201 )
202 .with_label(*prev, "previously declared here"),
203 );
204 } else {
205 seen_cases.insert(case.name.clone(), case.name_span);
206 }
207 }
208 }
209 }
210
211 for &i in indices {
213 if let Some(t) = parsed[i].test() {
214 crate::test_env::check_env_clauses(t, errors);
215 }
216 }
217
218 let target_stubs = resolve_stubs(
227 target_name,
228 target_kind,
229 indices,
230 parsed,
231 unit_tables,
232 unit_consumes,
233 errors,
234 );
235
236 if had_dup {
237 continue;
239 }
240
241 let bodies_errs = check_test_bodies(
245 target_name,
246 target_kind,
247 indices,
248 parsed,
249 &target_stubs,
250 unit_tables,
251 exports_visibility,
252 unit_consumes,
253 unit_consumes_aliases,
254 unit_uses,
255 refs,
256 tys,
257 );
258 let bodies_failed = !bodies_errs.is_empty();
259 errors.extend(bodies_errs);
260
261 if bodies_failed {
262 continue;
263 }
264
265 ready.insert(target_name.clone(), target_stubs);
266 }
267
268 ready
269}
270
271fn resolve_stubs(
278 target_name: &str,
279 target_kind: UnitKind,
280 indices: &[usize],
281 parsed: &[ParsedFile],
282 unit_tables: &HashMap<String, UnitTable>,
283 unit_consumes: &HashMap<String, Vec<String>>,
284 errors: &mut Vec<CompileError>,
285) -> HashMap<String, ResolvedStub> {
286 let target_table = unit_tables.get(target_name);
287 let target_consumed = unit_consumes.get(target_name).cloned().unwrap_or_default();
288
289 let mut collected: Vec<(StubClause, StubScope, PathBuf)> = Vec::new();
293 for &i in indices {
294 let Some(t) = parsed[i].test() else { continue };
295 for case in &t.cases {
296 for pc in &case.stubs {
297 collected.push((
298 pc.clone(),
299 StubScope::Case(case.name.clone()),
300 parsed[i].identity_path(),
301 ));
302 }
303 }
304 }
305 let suites = indices
309 .iter()
310 .filter(|&&i| parsed[i].test().is_some())
311 .count();
312 for &i in indices {
313 let Some(t) = parsed[i].test() else { continue };
314 let scope = match suite_ordinal(i, indices, parsed) {
315 Some(n) if suites > 1 => StubScope::Suite(n),
316 _ => StubScope::Every,
317 };
318 for pc in &t.stubs {
319 collected.push((pc.clone(), scope.clone(), parsed[i].identity_path()));
320 }
321 }
322
323 let resolve_cap = |name: &str| -> Option<CapabilityDecl> {
327 target_table
328 .and_then(|t| t.capabilities.get(name).cloned())
329 .or_else(|| {
330 target_consumed.iter().find_map(|q| {
331 unit_tables
332 .get(q)
333 .and_then(|t| t.capabilities.get(name).cloned())
334 })
335 })
336 };
337
338 let mut out: HashMap<String, ResolvedStub> = HashMap::new();
339 for (pc, scope, identity_path) in collected {
340 let cap_name = pc.capability.name.clone();
341 let Some(cap_decl) = resolve_cap(&cap_name) else {
342 let note = if target_kind == UnitKind::Commons {
345 "commons have no capability seams — `stub` overrides a capability the target context declares or consumes"
346 } else {
347 "a `stub` clause names a capability the target context declares or reaches through a consumed context"
348 };
349 errors.push(
350 CompileError::new(
351 "bynk.stub.not_a_seam",
352 pc.capability.span,
353 format!("`{cap_name}` is not a capability seam of `{target_name}`",),
354 )
355 .with_note(note),
356 );
357 continue;
358 };
359 let Some(op_decl) = cap_decl.ops.iter().find(|o| o.name.name == pc.method.name) else {
360 errors.push(CompileError::new(
361 "bynk.stub.unknown_op",
362 pc.method.span,
363 format!(
364 "`{}` is not an operation of capability `{cap_name}`",
365 pc.method.name
366 ),
367 ));
368 continue;
369 };
370 if !op_decl.type_params.is_empty() {
378 errors.push(
379 CompileError::new(
380 "bynk.stub.generic_op",
381 pc.method.span,
382 format!(
383 "`{cap_name}.{}` declares its own type parameter — a generic capability operation cannot be stubbed at v1",
384 pc.method.name
385 ),
386 )
387 .with_note(
388 "test through the capability's real (external) provider instead, or restructure the test to avoid stubbing this operation",
389 ),
390 );
391 continue;
392 }
393 if let StubRhs::ReturnsEach(outcomes, span) = &pc.rhs
394 && outcomes.is_empty()
395 {
396 errors.push(CompileError::new(
397 "bynk.stub.bad_sequence",
398 *span,
399 format!(
400 "`stub {cap_name}.{} returns each []` has no outcomes — a sequence needs at least one",
401 pc.method.name
402 ),
403 ));
404 continue;
405 }
406 let entry = out.entry(cap_name.clone()).or_insert_with(|| ResolvedStub {
407 cap: cap_name.clone(),
408 cap_decl: cap_decl.clone(),
409 clauses: Vec::new(),
410 clause_scopes: Vec::new(),
411 identity_path: identity_path.clone(),
412 });
413 entry.clauses.push(pc);
414 entry.clause_scopes.push(scope);
415 }
416 out
417}
418
419pub fn infer_participants(
428 target: &str,
429 unit_consumes: &HashMap<String, Vec<String>>,
430 kinds: &BTreeMap<String, UnitKind>,
431) -> Vec<String> {
432 let mut seen: HashSet<String> = HashSet::new();
433 let mut order: Vec<String> = Vec::new();
434 let mut queue: Vec<String> = vec![target.to_string()];
435 seen.insert(target.to_string());
436 let mut head = 0;
437 while head < queue.len() {
438 let node = queue[head].clone();
439 head += 1;
440 order.push(node.clone());
441 if let Some(deps) = unit_consumes.get(&node) {
442 for d in deps {
443 if seen.insert(d.clone()) {
444 queue.push(d.clone());
445 }
446 }
447 }
448 }
449 order.retain(|u| kinds.get(u) == Some(&UnitKind::Context));
450 order
451}
452
453pub fn infer_harness_participants(
462 target: &str,
463 unit_consumes: &HashMap<String, Vec<String>>,
464 kinds: &BTreeMap<String, UnitKind>,
465 event_subscribers: &BTreeMap<(String, String), Vec<(String, String)>>,
466) -> Vec<String> {
467 let mut seen: HashSet<String> = HashSet::new();
468 let mut order: Vec<String> = Vec::new();
469 let mut queue: Vec<String> = vec![target.to_string()];
470 seen.insert(target.to_string());
471 let mut head = 0;
472 while head < queue.len() {
473 let node = queue[head].clone();
474 head += 1;
475 order.push(node.clone());
476 let consumed = unit_consumes.get(&node).into_iter().flatten();
477 let subscribers = event_subscribers
478 .iter()
479 .filter(|((owner, _), _)| *owner == node)
480 .flat_map(|(_, subs)| subs.iter().map(|(ctx, _)| ctx));
481 for next in consumed.chain(subscribers) {
482 if seen.insert(next.clone()) {
483 queue.push(next.clone());
484 }
485 }
486 }
487 order.retain(|u| kinds.get(u) == Some(&UnitKind::Context));
488 order
489}
490
491#[allow(clippy::too_many_arguments)]
511pub fn phase_integration_bodies(
512 integration_groups: &BTreeMap<String, Vec<usize>>,
513 parsed: &[ParsedFile],
514 kinds: &BTreeMap<String, UnitKind>,
515 unit_tables: &HashMap<String, UnitTable>,
516 unit_consumes: &HashMap<String, Vec<String>>,
517 unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
518 unit_uses: &HashMap<String, Vec<String>>,
519 errors: &mut Vec<CompileError>,
520 refs: &mut RefSink,
521 tys: &Arc<Types>,
522) -> HashMap<String, resolver::CrossContextInfo> {
523 let mut ready: HashMap<String, resolver::CrossContextInfo> = HashMap::new();
524
525 let mut sorted: Vec<&String> = integration_groups.keys().collect();
526 sorted.sort();
527
528 for group_name in sorted {
529 let indices = integration_groups.get(group_name).unwrap();
530 let first = indices[0];
531 let Some(decl) = parsed[first].integration() else {
532 continue;
533 };
534 let suite_target = decl.target.joined();
538 let participants = infer_participants(&suite_target, unit_consumes, kinds);
539
540 let mut bad = false;
541
542 let has_serialisation_edge = unit_tables.get(&suite_target).is_some_and(|t| {
558 t.services.values().any(|s| {
559 matches!(
560 s.protocol,
561 bynk_syntax::ast::ServiceProtocol::Http
562 | bynk_syntax::ast::ServiceProtocol::Queue { .. }
563 )
564 })
565 });
566 if participants.len() < 2 && !has_serialisation_edge {
567 errors.push(
568 CompileError::new(
569 "bynk.tier.system_needs_wire",
570 decl.target.span,
571 format!(
572 "`system`-tier suite for `{suite_target}` has no serialisation edge — the target consumes no other context and exposes no `http` or `queue` service",
573 ),
574 )
575 .with_note(
576 "a `system` case crosses a real serialise → JSON → deserialise boundary; this target has none to cross, so `unit` already covers it",
577 ),
578 );
579 bad = true;
580 }
581
582 let mut seen_cases: HashMap<String, Span> = HashMap::new();
584 for &i in indices {
585 let Some(d) = parsed[i].integration() else {
586 continue;
587 };
588 for case in &d.cases {
589 if let Some(prev) = seen_cases.get(&case.name) {
590 errors.push(
591 CompileError::new(
592 "bynk.suite.duplicate_case_name",
593 case.name_span,
594 format!(
595 "test case `\"{}\"` is declared more than once in tests targeting `{suite_target}`",
596 case.name
597 ),
598 )
599 .with_label(*prev, "previously declared here"),
600 );
601 bad = true;
602 } else {
603 seen_cases.insert(case.name.clone(), case.name_span);
604 }
605 }
606 }
607
608 for &i in indices {
610 if let Some(d) = parsed[i].integration() {
611 crate::test_env::check_env_clauses(d, errors);
612 }
613 }
614
615 for &i in indices {
625 let Some(d) = parsed[i].integration() else {
626 continue;
627 };
628 let suite_is_system = d.tier == Some(bynk_syntax::ast::TestTier::System);
629 let has_lower = d
630 .cases
631 .iter()
632 .any(|c| case_effective_tier(c, d) != bynk_syntax::ast::TestTier::System);
633 if !has_lower {
634 continue;
635 }
636 for case in &d.cases {
637 let tier = case_effective_tier(case, d);
638 let flagged = if suite_is_system {
641 tier != bynk_syntax::ast::TestTier::System
642 } else {
643 case.tier == Some(bynk_syntax::ast::TestTier::System)
644 };
645 if !flagged {
646 continue;
647 }
648 let (message, advice) = if suite_is_system {
649 (
650 format!(
651 "case `\"{}\"` is `{}`-tier, but its suite is `as system`",
652 case.name,
653 tier.as_str()
654 ),
655 format!(
656 "move this case into a separate `suite {suite_target}` without `as system`"
657 ),
658 )
659 } else {
660 (
661 format!(
662 "case `\"{}\"` is `as system`, but other cases in its suite run below `system`",
663 case.name
664 ),
665 format!(
666 "move this case into its own `suite {suite_target} as system {{ … }}`, addressing services by context path (`{suite_target}.<service>(…)`)"
667 ),
668 )
669 };
670 errors.push(
671 CompileError::new("bynk.tier.mixed_system_suite", case.name_span, message)
672 .with_note(format!(
673 "a `system` case runs against deployed Workers, so its suite holds `system` cases only; {advice}"
674 )),
675 );
676 }
677 }
678
679 for &i in indices {
686 let Some(d) = parsed[i].integration() else {
687 continue;
688 };
689 let system = bynk_syntax::ast::TestTier::System;
690 let suite_stubs = d
691 .stubs
692 .iter()
693 .filter(|_| suite_effective_tier_is_system(d))
694 .map(|s| (s, "suite"));
695 let case_stubs = d
696 .cases
697 .iter()
698 .filter(|c| case_effective_tier(c, d) == system)
699 .flat_map(|c| c.stubs.iter().map(|s| (s, "case")));
700 for (stub, scope) in suite_stubs.chain(case_stubs) {
701 errors.push(
702 CompileError::new(
703 "bynk.stub.at_system",
704 stub.span,
705 format!(
706 "`stub {}.{}` is in a `system`-tier {scope}, where a `stub` has no effect",
707 stub.capability.name, stub.method.name
708 ),
709 )
710 .with_note(
711 "a `system` case runs each context as the Worker it deploys as, which builds its own capability providers; no seam can be overridden there",
712 )
713 .with_note(format!(
714 "move the case into a `suite {suite_target}` below `system` (`unit`, or `as integration`), where a `stub` overrides the seam"
715 )),
716 );
717 }
718 }
719
720 if bad {
721 continue;
722 }
723
724 let harness_name = group_name.clone();
726 let mut uses_targets: Vec<String> = Vec::new();
727 for &i in indices {
728 if let Some(d) = parsed[i].integration() {
729 for u in &d.uses {
730 let q = u.target.joined();
731 if !uses_targets.contains(&q) {
732 uses_targets.push(q);
733 }
734 }
735 }
736 }
737 let mut harness_consumes = unit_consumes.clone();
738 harness_consumes.insert(harness_name.clone(), participants.clone());
739 let mut harness_uses = unit_uses.clone();
740 harness_uses.insert(harness_name.clone(), uses_targets.clone());
741 let cross_context = build_cross_context_info(
742 &harness_name,
743 &harness_consumes,
744 unit_consumes_aliases,
745 &harness_uses,
746 unit_tables,
747 );
748
749 let mut body_errs: Vec<CompileError> = Vec::new();
751 let view = integration_view(&uses_targets, &participants, unit_tables, unit_uses);
754 for c in &view.conflicts {
760 let span = indices
761 .iter()
762 .filter_map(|&i| parsed[i].integration())
763 .flat_map(|d| &d.uses)
764 .find(|u| u.target.joined() == c.via)
765 .map(|u| u.target.span)
766 .unwrap_or(decl.target.span);
767 let names = c
768 .bound
769 .as_ref()
770 .map(|b| format!("`{b}`'s type"))
771 .unwrap_or_else(|| "another declaration".to_string());
772 body_errs.push(view_conflict_error(c, span, &names));
773 }
774 let mut harness_resolution = uses_targets.clone();
777 harness_resolution.extend(participants.iter().cloned());
778 refs.declare_namespace(&harness_name, harness_resolution);
779 for &i in indices {
780 let Some(d) = parsed[i].integration() else {
781 continue;
782 };
783 refs.enter_file(
784 &parsed[i].identity_path(),
785 &harness_name,
786 parsed[i].is_synthetic(),
787 );
788 body_errs.extend(resolver::check_hidden_type_names_in_suite(
791 d,
792 None,
793 &view.hidden,
794 ));
795 let system_services = target_test_services(
798 participants.first().map(String::as_str),
799 unit_tables,
800 &cross_context.consumed_contexts,
801 );
802 let system_actors =
803 target_test_actors(participants.first().and_then(|t| unit_tables.get(t)));
804 for case in &d.cases {
805 let (_, case_callees) = typecheck_integration_case_body(
806 &participants,
807 &view,
808 case,
809 &cross_context,
810 unit_tables,
811 &mut body_errs,
812 refs,
813 tys,
814 );
815 check_faults_tier(case, case_effective_tier(case, d), &mut body_errs);
816 check_event_address_tier(
817 case,
818 case_effective_tier(case, d),
819 &case_callees,
820 &mut body_errs,
821 );
822 if case_effective_tier(case, d) == bynk_syntax::ast::TestTier::System {
823 check_unsigned_route_at_system(
824 case,
825 &case_callees,
826 &system_services,
827 &system_actors,
828 &mut body_errs,
829 );
830 }
831 if !matches!(
836 case_effective_tier(case, d),
837 bynk_syntax::ast::TestTier::System
838 ) && block_uses_wire(&case.body)
839 {
840 body_errs.push(CompileError::new(
841 "bynk.test.wire_needs_system",
842 case.name_span,
843 format!(
844 "case `\"{}\"` uses `Wire(...)` but is not a `system`-tier case",
845 case.name
846 ),
847 ).with_note(
848 "`Wire` hands raw, pre-validation input to the real boundary; promote the case with `as system`, or pass a typed argument",
849 ));
850 }
851 if !matches!(
855 case_effective_tier(case, d),
856 bynk_syntax::ast::TestTier::System
857 ) && block_uses_nobody(&case.body)
858 {
859 body_errs.push(CompileError::new(
860 "bynk.test.credential_needs_system",
861 case.name_span,
862 format!(
863 "case `\"{}\"` drives `by Nobody` but is not a `system`-tier case",
864 case.name
865 ),
866 ).with_note(
867 "`by Nobody` presents no credential to the real auth seam (the 401 path), which exists only at `system`; promote the case with `as system`, or supply `by <Actor>(<identity>)`",
868 ));
869 }
870 check_bad_signature_tier(case, case_effective_tier(case, d), &mut body_errs);
871 }
872 }
873 let bodies_failed = !body_errs.is_empty();
874 errors.extend(body_errs);
875 if bodies_failed {
876 continue;
877 }
878
879 ready.insert(group_name.clone(), cross_context);
880 }
881
882 ready
883}
884
885#[allow(clippy::too_many_arguments)]
897pub fn typecheck_integration_case_body(
898 participants: &[String],
899 view: &SuiteView,
900 case: &Case,
901 cross_context: &resolver::CrossContextInfo,
902 unit_tables: &HashMap<String, UnitTable>,
903 errors: &mut Vec<CompileError>,
904 refs: &mut RefSink,
905 tys: &Arc<Types>,
906) -> (
907 HashMap<ExprId, checker::TypedExpr>,
908 HashMap<ExprId, checker::Callee>,
909) {
910 let SuiteView {
912 types,
913 fns,
914 methods,
915 ..
916 } = view.clone();
917
918 let synthetic_commons = Commons {
919 name: QualifiedName {
920 parts: vec![Ident {
921 name: "integration".to_string(),
922 span: Span::default(),
923 }],
924 span: Span::default(),
925 },
926 items: Vec::new(),
927 uses: Vec::new(),
928 documentation: None,
929 form: CommonsForm::Brace,
930 span: Span::default(),
931 trivia: Trivia::default(),
932 trailing_comments: Vec::new(),
933 };
934 let no_local_types = HashMap::new();
939 let no_local_events = HashMap::new();
940 let resolved = ResolvedCommons::new(
941 synthetic_commons,
942 types,
943 &no_local_types,
944 fns,
945 methods,
946 HashMap::new(),
947 &no_local_events,
948 cross_context.clone(),
949 HashMap::new(),
950 false,
952 HashSet::new(),
953 )
954 .with_assets(view.assets.clone())
956 .with_embeds(view.embeds.clone());
957
958 let unit_span = case.span;
959 let synthetic_return = TypeRef::Effect(
960 Box::new(TypeRef::Result(
961 Box::new(TypeRef::Unit(unit_span)),
962 Box::new(TypeRef::ValidationError(unit_span)),
963 unit_span,
964 )),
965 unit_span,
966 );
967 let return_ty = checker::resolve_type_ref(&synthetic_return, &resolved.types, tys).unwrap();
968 let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
969 let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
970 let mut no_hints = HintSink::new();
972 let mut no_locals = LocalsSink::new();
973 let mut no_requirements = RequirementSink::new();
975 let _ = checker::check_body(
976 &resolved,
977 &case.body,
978 return_ty,
979 case.span,
980 HashMap::new(),
981 checker::CapabilityCtx::default(),
982 target_test_services(
986 participants.first().map(String::as_str),
987 unit_tables,
988 &cross_context.consumed_contexts,
989 ),
990 target_test_actors(participants.first().and_then(|t| unit_tables.get(t))),
991 None,
992 checker::CheckSinks {
993 tys,
994 expr_types: &mut expr_types,
995 errors,
996 refs,
997 hints: &mut no_hints,
998 locals: &mut no_locals,
999 requirements: &mut no_requirements,
1000 callees: &mut callees,
1001 },
1002 );
1003 (expr_types, callees)
1004}
1005
1006fn view_conflict_error(
1010 c: &crate::project_model::ViewConflict,
1011 span: Span,
1012 names: &str,
1013) -> CompileError {
1014 CompileError::new(
1015 "bynk.uses.name_conflict",
1016 span,
1017 format!(
1018 "`{via}` brings in declarations that use type `{t}` from `{owner}`, but `{t}` in \
1019 this suite names {names}",
1020 via = c.via,
1021 t = c.name,
1022 owner = c.owner,
1023 ),
1024 )
1025 .with_note(
1026 "rename one of the two types; the imported declarations cannot be checked against a \
1027 different type of the same name",
1028 )
1029}
1030
1031fn first_test_target_span(indices: &[usize], parsed: &[ParsedFile]) -> Span {
1032 indices
1033 .first()
1034 .and_then(|&i| parsed[i].test().map(|t| t.target.span))
1035 .unwrap_or_default()
1036}
1037
1038#[allow(clippy::too_many_arguments)]
1043fn check_test_bodies(
1044 target_name: &str,
1045 target_kind: UnitKind,
1046 indices: &[usize],
1047 parsed: &[ParsedFile],
1048 stubs: &HashMap<String, ResolvedStub>,
1049 unit_tables: &HashMap<String, UnitTable>,
1050 exports_visibility: &HashMap<String, HashMap<String, Visibility>>,
1051 unit_consumes: &HashMap<String, Vec<String>>,
1052 unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
1053 unit_uses: &HashMap<String, Vec<String>>,
1054 refs: &mut RefSink,
1055 tys: &Arc<Types>,
1056) -> Vec<CompileError> {
1057 let mut errors = Vec::new();
1058
1059 let privileged = build_privileged_resolved(
1062 target_name,
1063 unit_tables,
1064 unit_uses,
1065 unit_consumes,
1066 unit_consumes_aliases,
1067 );
1068
1069 if !stubs.is_empty()
1074 && let Some((resolved, _)) = &privileged
1075 {
1076 for rp in stubs.values() {
1077 refs.enter_file(&rp.identity_path, target_name, false);
1078 for clause in &rp.clauses {
1079 let Some(op) = rp
1080 .cap_decl
1081 .ops
1082 .iter()
1083 .find(|o| o.name.name == clause.method.name)
1084 else {
1085 continue;
1086 };
1087 let check_value = |e: &Expr, errors: &mut Vec<CompileError>| {
1088 if !stub_value_typechecks(e, op, resolved, tys) {
1089 errors.push(CompileError::new(
1090 "bynk.stub.rhs_type",
1091 e.span,
1092 format!(
1093 "the value provided for `{}.{}` does not match the operation's declared return type `{}`",
1094 rp.cap,
1095 op.name.name,
1096 ts_type_ref_display(&op.return_type),
1097 ),
1098 ));
1099 }
1100 };
1101 match &clause.rhs {
1102 StubRhs::Returns(e) => check_value(e, &mut errors),
1103 StubRhs::ReturnsEach(outcomes, _) => {
1104 for o in outcomes {
1105 if let SeqOutcome::Value(e) = o {
1106 check_value(e, &mut errors);
1107 }
1108 }
1109 }
1110 StubRhs::Fails(_) => {}
1111 }
1112 }
1113 }
1114 }
1115
1116 let crossing = CrossingServices::of(
1119 target_name,
1120 unit_tables,
1121 unit_consumes,
1122 unit_consumes_aliases,
1123 );
1124
1125 let target_shadowed = privileged
1130 .as_ref()
1131 .map(|(r, _)| (r.shadowed_imports.clone(), r.types.clone()))
1132 .unwrap_or_default();
1133 let reach = privileged.map(|(_, r)| r).unwrap_or_default();
1134 let hidden = reach.hidden;
1135
1136 for c in &reach.conflicts {
1142 let Some(bound) = &c.bound else {
1143 continue;
1144 };
1145 let consumed = unit_consumes
1146 .get(target_name)
1147 .is_some_and(|cs| cs.contains(bound));
1148 let exported = exports_visibility
1149 .get(bound)
1150 .is_some_and(|e| e.contains_key(&c.name));
1151 if consumed && !exported {
1152 errors.push(view_conflict_error(
1153 c,
1154 first_test_target_span(indices, parsed),
1155 &format!("`{bound}`'s unexported type"),
1156 ));
1157 }
1158 }
1159
1160 for &i in indices {
1163 let Some(test_decl) = parsed[i].test() else {
1164 continue;
1165 };
1166 errors.extend(resolver::check_hidden_type_names_in_suite(
1167 test_decl,
1168 Some(target_name),
1169 &hidden,
1170 ));
1171 errors.extend(resolver::check_shadowed_type_names_in_suite(
1173 test_decl,
1174 &target_shadowed.0,
1175 &target_shadowed.1,
1176 ));
1177 refs.enter_file(
1180 &parsed[i].identity_path(),
1181 target_name,
1182 parsed[i].is_synthetic(),
1183 );
1184 for case in &test_decl.cases {
1185 check_test_case_body(
1186 target_name,
1187 target_kind,
1188 case,
1189 unit_tables,
1190 unit_uses,
1191 unit_consumes,
1192 unit_consumes_aliases,
1193 &mut errors,
1194 refs,
1195 tys,
1196 );
1197 let tier = case_effective_tier(case, test_decl);
1198 check_faults_tier(case, tier, &mut errors);
1199 check_bad_signature_tier(case, tier, &mut errors);
1200 if tier != bynk_syntax::ast::TestTier::System {
1201 crossing.check_case(target_name, case, tier, &mut errors);
1202 }
1203 }
1204 for prop in &test_decl.properties {
1211 check_property_body(
1212 target_name,
1213 target_kind,
1214 prop,
1215 unit_tables,
1216 unit_uses,
1217 unit_consumes,
1218 unit_consumes_aliases,
1219 &mut errors,
1220 refs,
1221 tys,
1222 );
1223 crossing.check_property(target_name, prop, &mut errors);
1224 check_advance_in_property(prop, &mut errors);
1225 }
1226 }
1227
1228 errors
1229}
1230
1231fn check_advance_in_property(
1236 prop: &bynk_syntax::ast::PropertyDecl,
1237 errors: &mut Vec<CompileError>,
1238) {
1239 fn collect(b: &Block, out: &mut Vec<Span>) {
1240 for s in &b.statements {
1241 if let Statement::Advance(a) = s {
1242 out.push(a.keyword.merge(a.cap.span));
1243 }
1244 }
1245 let mut exprs = Vec::new();
1246 for s in &b.statements {
1247 bynk_syntax::ast::statement_exprs(s, &mut exprs);
1248 }
1249 exprs.push(&b.tail);
1250 for e in exprs {
1251 walk(e, out);
1252 }
1253 }
1254 fn walk(e: &Expr, out: &mut Vec<Span>) {
1257 match &e.kind {
1258 ExprKind::Block(inner) => collect(inner, out),
1259 ExprKind::If {
1260 cond,
1261 then_block,
1262 else_block,
1263 } => {
1264 walk(cond, out);
1265 collect(then_block, out);
1266 collect(else_block, out);
1267 }
1268 ExprKind::Match { discriminant, arms } => {
1269 walk(discriminant, out);
1270 for arm in arms {
1271 if let Some(g) = &arm.guard {
1272 walk(g, out);
1273 }
1274 match &arm.body {
1275 bynk_syntax::ast::MatchBody::Block(b) => collect(b, out),
1276 bynk_syntax::ast::MatchBody::Expr(x) => walk(x, out),
1277 }
1278 }
1279 }
1280 _ => {
1281 for c in bynk_syntax::ast::expr_children(e) {
1282 walk(c, out);
1283 }
1284 }
1285 }
1286 }
1287 let mut spans = Vec::new();
1288 collect(&prop.forall.body, &mut spans);
1289 for span in spans {
1290 errors.push(
1291 CompileError::new(
1292 "bynk.test.advance_outside_case",
1293 span,
1294 format!(
1295 "`advance` is only valid inside a `case` body, but this is in property `\"{}\"`",
1296 prop.name
1297 ),
1298 )
1299 .with_note(
1300 "a property runs its body once per generated input, with no clock of its own; move the time-dependent check into a `case`",
1301 ),
1302 );
1303 }
1304}
1305
1306pub fn value_block(e: &Expr) -> Block {
1316 Block {
1317 statements: Vec::new(),
1318 tail: Box::new(e.clone()),
1319 span: e.span,
1320 tail_leading_comments: Vec::new(),
1321 implicit_tail: false,
1322 }
1323}
1324
1325fn stub_value_typechecks(
1330 e: &Expr,
1331 op: &CapabilityOp,
1332 resolved: &ResolvedCommons,
1333 tys: &Arc<Types>,
1334) -> bool {
1335 let block = value_block(e);
1336 let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
1337 let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
1338 let mut errs: Vec<CompileError> = Vec::new();
1339 checker::check_handler_body(
1340 resolved,
1341 checker::HandlerBodyCheck::new(&block, &op.return_type, &op.params, &[]),
1342 checker::CheckSinks {
1343 tys,
1344 expr_types: &mut expr_types,
1345 errors: &mut errs,
1346 refs: &mut RefSink::new(),
1347 hints: &mut HintSink::new(),
1348 locals: &mut LocalsSink::new(),
1349 requirements: &mut RequirementSink::new(),
1350 callees: &mut callees,
1351 },
1352 );
1353 errs.is_empty()
1354}
1355
1356fn block_uses_wire(block: &Block) -> bool {
1360 fn contains_wire(e: &Expr) -> bool {
1367 matches!(e.kind, ExprKind::Wire(_))
1368 || bynk_syntax::ast::expr_children(e)
1369 .into_iter()
1370 .any(contains_wire)
1371 }
1372 let mut exprs = Vec::new();
1375 for s in &block.statements {
1376 bynk_syntax::ast::statement_exprs(s, &mut exprs);
1377 }
1378 exprs.into_iter().any(contains_wire) || contains_wire(&block.tail)
1379}
1380
1381type CrossCall = (String, String);
1383
1384type AgentHandler = (String, String);
1386
1387struct CrossingServices<'a> {
1407 services: HashMap<String, CrossCall>,
1409 agents: HashMap<AgentHandler, CrossCall>,
1411 table: Option<&'a UnitTable>,
1413 consumed: &'a [String],
1415 aliases: Option<&'a HashMap<String, String>>,
1416 unit_tables: &'a HashMap<String, UnitTable>,
1417}
1418
1419impl<'a> CrossingServices<'a> {
1420 fn of(
1421 target_name: &str,
1422 unit_tables: &'a HashMap<String, UnitTable>,
1423 unit_consumes: &'a HashMap<String, Vec<String>>,
1424 unit_consumes_aliases: &'a HashMap<String, HashMap<String, String>>,
1425 ) -> Self {
1426 let mut this = CrossingServices {
1427 services: HashMap::new(),
1428 agents: HashMap::new(),
1429 table: unit_tables.get(target_name),
1430 consumed: unit_consumes
1431 .get(target_name)
1432 .map(Vec::as_slice)
1433 .unwrap_or(&[]),
1434 aliases: unit_consumes_aliases.get(target_name),
1435 unit_tables,
1436 };
1437 let Some(table) = this.table else {
1438 return this;
1439 };
1440 if this.consumed.is_empty() {
1441 return this;
1442 }
1443 let mut agent_edges: HashMap<AgentHandler, Vec<AgentHandler>> = HashMap::new();
1446 for (agent, decl) in &table.agents {
1447 for h in &decl.handlers {
1448 let Some(method) = &h.method_name else {
1449 continue;
1450 };
1451 let key = (agent.clone(), method.name.clone());
1452 let (hit, edges) = this.scan(&h.body);
1453 if let Some(hit) = hit {
1454 this.agents.insert(key.clone(), hit);
1455 }
1456 agent_edges.insert(key, edges);
1457 }
1458 }
1459 loop {
1460 let mut changed = false;
1461 for (key, edges) in &agent_edges {
1462 if this.agents.contains_key(key) {
1463 continue;
1464 }
1465 if let Some(hit) = edges.iter().find_map(|e| this.agents.get(e).cloned()) {
1466 this.agents.insert(key.clone(), hit);
1467 changed = true;
1468 }
1469 }
1470 if !changed {
1471 break;
1472 }
1473 }
1474 for (name, decl) in &table.services {
1475 for h in &decl.handlers {
1476 let (hit, edges) = this.scan(&h.body);
1477 let reached =
1478 hit.or_else(|| edges.iter().find_map(|e| this.agents.get(e).cloned()));
1479 if let Some(reached) = reached {
1480 this.services.entry(name.clone()).or_insert(reached);
1481 }
1482 }
1483 }
1484 this
1485 }
1486
1487 fn resolve(&self, chain: &str) -> Option<&String> {
1489 self.aliases
1490 .and_then(|a| a.get(chain))
1491 .or_else(|| self.consumed.iter().find(|c| *c == chain))
1492 }
1493
1494 fn cross_call(&self, e: &Expr) -> Option<CrossCall> {
1496 let ExprKind::MethodCall {
1497 receiver, method, ..
1498 } = &e.kind
1499 else {
1500 return None;
1501 };
1502 let ctx = self.resolve(&receiver_chain(receiver)?)?;
1503 let callee = self.unit_tables.get(ctx)?;
1504 callee
1505 .services
1506 .contains_key(&method.name)
1507 .then(|| (ctx.clone(), method.name.clone()))
1508 }
1509
1510 fn dispatch(&self, e: &Expr, bound: &HashMap<String, String>) -> Option<AgentHandler> {
1513 let table = self.table?;
1514 let ExprKind::MethodCall {
1515 receiver, method, ..
1516 } = &e.kind
1517 else {
1518 return None;
1519 };
1520 let agent = match &receiver.kind {
1521 ExprKind::Call { name, .. } if table.agents.contains_key(&name.name) => {
1522 name.name.clone()
1523 }
1524 ExprKind::Ident(x) => bound.get(&x.name)?.clone(),
1525 _ => return None,
1526 };
1527 Some((agent, method.name.clone()))
1528 }
1529
1530 fn scan(&self, block: &Block) -> (Option<CrossCall>, Vec<AgentHandler>) {
1533 let bound = self.agent_bindings(block);
1534 let mut hit = None;
1535 let mut edges = Vec::new();
1536 for e in exprs_in_order(block) {
1537 if hit.is_none() {
1538 hit = self.cross_call(e);
1539 }
1540 if let Some(edge) = self.dispatch(e, &bound) {
1541 edges.push(edge);
1542 }
1543 }
1544 (hit, edges)
1545 }
1546
1547 fn agent_bindings(&self, block: &Block) -> HashMap<String, String> {
1550 let mut bound = HashMap::new();
1551 let Some(table) = self.table else {
1552 return bound;
1553 };
1554 for b in blocks_deep(block) {
1555 for s in &b.statements {
1556 if let Statement::Let(l) | Statement::EffectLet(l) = s
1557 && let ExprKind::Call { name, .. } = &l.value.kind
1558 && table.agents.contains_key(&name.name)
1559 {
1560 bound.insert(l.name.name.clone(), name.name.clone());
1561 }
1562 }
1563 }
1564 bound
1565 }
1566
1567 fn check_block(
1573 &self,
1574 target_name: &str,
1575 subject: &str,
1576 below: &str,
1577 body: &Block,
1578 errors: &mut Vec<CompileError>,
1579 ) {
1580 if self.consumed.is_empty() {
1581 return;
1582 }
1583 let bound = self.agent_bindings(body);
1584 let shadowed: HashSet<String> = blocks_deep(body)
1586 .iter()
1587 .flat_map(|b| &b.statements)
1588 .filter_map(|s| match s {
1589 Statement::Let(l) | Statement::EffectLet(l) => Some(l.name.name.clone()),
1590 _ => None,
1591 })
1592 .collect();
1593 for e in exprs_in_order(body) {
1594 let ExprKind::MethodCall {
1595 receiver,
1596 method,
1597 args,
1598 ..
1599 } = &e.kind
1600 else {
1601 continue;
1602 };
1603 if bynk_syntax::ast::HttpMethod::from_ident(&method.name).is_some()
1608 && args
1609 .first()
1610 .is_some_and(|a| !matches!(a.kind, ExprKind::StrLit(_)))
1611 {
1612 continue;
1613 }
1614 let through = match &receiver.kind {
1615 ExprKind::Ident(svc) if !shadowed.contains(&svc.name) => self
1617 .services
1618 .get(&svc.name)
1619 .map(|reached| (format!("`{}`", svc.name), Some(svc.name.clone()), reached)),
1620 _ => None,
1621 };
1622 let through = through.or_else(|| {
1623 let (agent, handler) = self.dispatch(e, &bound)?;
1624 let reached = self.agents.get(&(agent.clone(), handler.clone()))?;
1625 Some((format!("`{agent}.{handler}`"), None, reached))
1626 });
1627 let message = match through {
1628 Some((via, _, (ctx, svc))) => {
1629 format!(
1630 "{subject} calls {via}, which calls `{ctx}.{svc}` in another context, but {below}"
1631 )
1632 }
1633 None => match self.cross_call(e) {
1634 Some((ctx, svc)) => {
1635 format!("{subject} calls `{ctx}.{svc}` in another context, but {below}")
1636 }
1637 None => continue,
1638 },
1639 };
1640 let service = match &receiver.kind {
1641 ExprKind::Ident(svc) if self.services.contains_key(&svc.name) => {
1642 Some(svc.name.clone())
1643 }
1644 _ => None,
1645 };
1646 let promote = match service {
1647 Some(s) => format!(
1648 "move it into a `system` suite (`suite {target_name} as system {{ … }}`), where the service is addressed by its context path (`{target_name}.{s}(…)`)"
1649 ),
1650 None => format!(
1651 "move it into a `system` suite (`suite {target_name} as system {{ … }}`) and drive it through one of `{target_name}`'s services (`{target_name}.<service>(…)`), which reaches the other context over the real wire"
1652 ),
1653 };
1654 errors.push(
1655 CompileError::new("bynk.tier.cross_context_needs_system", e.span, message)
1656 .with_note(format!(
1657 "below `system` a test runs in-process, with no other context stood up to call (`stub` doubles capabilities, not a context's services); {promote}"
1658 )),
1659 );
1660 }
1661 }
1662
1663 fn check_case(
1665 &self,
1666 target_name: &str,
1667 case: &Case,
1668 tier: TestTier,
1669 errors: &mut Vec<CompileError>,
1670 ) {
1671 let tier = tier.as_str();
1672 let article = if tier == "integration" { "an" } else { "a" };
1674 self.check_block(
1675 target_name,
1676 &format!("case `\"{}\"`", case.name),
1677 &format!("it is {article} `{tier}`-tier case"),
1678 &case.body,
1679 errors,
1680 );
1681 }
1682
1683 fn check_property(
1685 &self,
1686 target_name: &str,
1687 prop: &PropertyDecl,
1688 errors: &mut Vec<CompileError>,
1689 ) {
1690 self.check_block(
1691 target_name,
1692 &format!("property `\"{}\"`", prop.name),
1693 "a property runs in-process (it generates, and has no `system` tier)",
1694 &prop.forall.body,
1695 errors,
1696 );
1697 }
1698}
1699
1700fn receiver_chain(e: &Expr) -> Option<String> {
1703 match &e.kind {
1704 ExprKind::Ident(id) => Some(id.name.clone()),
1705 ExprKind::FieldAccess { receiver, field } => {
1706 Some(format!("{}.{}", receiver_chain(receiver)?, field.name))
1707 }
1708 _ => None,
1709 }
1710}
1711
1712fn exprs_in_order(block: &Block) -> Vec<&Expr> {
1716 let mut roots: Vec<&Expr> = Vec::new();
1717 for s in &block.statements {
1718 bynk_syntax::ast::statement_exprs(s, &mut roots);
1719 }
1720 roots.push(&block.tail);
1721 let mut stack: Vec<&Expr> = roots.into_iter().rev().collect();
1723 let mut out = Vec::new();
1724 while let Some(e) = stack.pop() {
1725 out.push(e);
1726 stack.extend(bynk_syntax::ast::expr_children(e).into_iter().rev());
1727 }
1728 out
1729}
1730
1731fn blocks_deep(block: &Block) -> Vec<&Block> {
1734 let mut out = vec![block];
1735 for e in exprs_in_order(block) {
1736 match &e.kind {
1737 ExprKind::Block(b) => out.push(b),
1738 ExprKind::If {
1739 then_block,
1740 else_block,
1741 ..
1742 } => {
1743 out.push(then_block);
1744 out.push(else_block);
1745 }
1746 ExprKind::Match { arms, .. } => {
1747 for arm in arms {
1748 if let MatchBody::Block(b) = &arm.body {
1749 out.push(b);
1750 }
1751 }
1752 }
1753 _ => {}
1754 }
1755 }
1756 out
1757}
1758
1759fn block_uses_faults(block: &Block) -> bool {
1765 !faults_claims(block).is_empty()
1766}
1767
1768fn faults_claims(block: &Block) -> Vec<&bynk_syntax::ast::FaultsExpr> {
1771 fn collect<'a>(e: &'a Expr, out: &mut Vec<&'a bynk_syntax::ast::FaultsExpr>) {
1772 if let ExprKind::Faults(f) = &e.kind {
1773 out.push(f);
1774 }
1775 for c in bynk_syntax::ast::expr_children(e) {
1776 collect(c, out);
1777 }
1778 }
1779 let mut exprs = Vec::new();
1780 for s in &block.statements {
1781 bynk_syntax::ast::statement_exprs(s, &mut exprs);
1782 }
1783 exprs.push(&block.tail);
1784 let mut out = Vec::new();
1785 for e in exprs {
1786 collect(e, &mut out);
1787 }
1788 out
1789}
1790
1791fn check_event_address_tier(
1798 case: &Case,
1799 tier: bynk_syntax::ast::TestTier,
1800 callees: &HashMap<ExprId, checker::Callee>,
1801 errors: &mut Vec<CompileError>,
1802) {
1803 if tier != bynk_syntax::ast::TestTier::System {
1804 return;
1805 }
1806 let mut services: Vec<&str> = callees
1807 .values()
1808 .filter_map(|c| match c {
1809 checker::Callee::TestService { service, address } if address == "event" => {
1810 Some(service.as_str())
1811 }
1812 _ => None,
1813 })
1814 .collect();
1815 services.sort_unstable();
1816 services.dedup();
1817 for service in services {
1818 errors.push(
1819 CompileError::new(
1820 "bynk.test.event_needs_in_process",
1821 case.name_span,
1822 format!(
1823 "case `\"{}\"` delivers an event with `{service}.event(…)`, but is a `system`-tier case",
1824 case.name
1825 ),
1826 )
1827 .with_note(
1828 "at `system` an event reaches a subscriber only through its publisher's fan-out; drive the handler that emits it, or deliver the event at `unit` or `integration`",
1829 ),
1830 );
1831 }
1832}
1833
1834pub fn remote_unsupported(
1854 case: &Case,
1855 callees: &HashMap<ExprId, checker::Callee>,
1856) -> Option<CompileError> {
1857 use checker::Callee;
1858 let callee_list: Vec<&Callee> = callees.values().collect();
1859 let refusal = |code: &'static str, what: String, note: &str| {
1860 Some(
1861 CompileError::new(
1862 code,
1863 case.name_span,
1864 format!(
1865 "case `\"{}\"` {what}, which a run against a real runtime can't honour",
1866 case.name
1867 ),
1868 )
1869 .with_note(note.to_string())
1870 .with_note(
1871 "run it in process (`bynk test` without `--against`), or pass `--skip-unsupported` to skip it with this reason",
1872 ),
1873 )
1874 };
1875 let named = |pick: &dyn Fn(&Callee) -> Option<String>| -> Option<String> {
1877 let mut names: Vec<String> = callee_list.iter().filter_map(|c| pick(c)).collect();
1878 names.sort();
1879 names.into_iter().next()
1880 };
1881 if let Some(svc) = named(&|c| match c {
1882 Callee::Cross { unit, service } => Some(format!("{unit}.{service}")),
1883 Callee::TestService { service, address } if address == "call" => Some(service.clone()),
1884 _ => None,
1885 }) {
1886 return refusal(
1887 "bynk.test.remote_unsupported_call",
1888 format!("addresses the `on call` service `{svc}`"),
1889 "an `on call` service is internal to a deployment, and each call is signed for one caller; drive it through an `http` route",
1890 );
1891 }
1892 if block_has_advance(&case.body) {
1893 return refusal(
1894 "bynk.test.remote_unsupported_advance",
1895 "moves the harness clock with `advance Clock`".to_string(),
1896 "a deployed Worker reads wall-clock time, which the test can't move",
1897 );
1898 }
1899 for (address, code, what) in [
1900 (
1901 "schedule",
1902 "bynk.test.remote_unsupported_schedule",
1903 "runs a cron handler",
1904 ),
1905 (
1906 "message",
1907 "bynk.test.remote_unsupported_queue",
1908 "delivers a queue message",
1909 ),
1910 ] {
1911 if let Some(svc) = named(&|c| match c {
1912 Callee::TestService {
1913 service,
1914 address: a,
1915 } if a == address || a.starts_with(&format!("{address} ")) => Some(service.clone()),
1916 _ => None,
1917 }) {
1918 return refusal(
1919 code,
1920 format!("{what} with `{svc}.{address}(…)`"),
1921 "a deployment exposes no public wire to that handler; only its `http` routes are reachable",
1922 );
1923 }
1924 }
1925 if let Some(svc) = named(&|c| match c {
1926 Callee::TestAssetFetch { service } => Some(service.clone()),
1927 _ => None,
1928 }) {
1929 return refusal(
1930 "bynk.test.remote_unsupported_asset",
1931 format!("fetches an asset with `{svc}.GET(…)`"),
1932 "an asset fetch is answered in process from the files the target ships, not by the deployment",
1933 );
1934 }
1935 None
1936}
1937
1938pub fn remote_clock_dependent(
1962 case: &Case,
1963 expr_types: &HashMap<ExprId, checker::TypedExpr>,
1964 tys: &Types,
1965) -> Option<CompileError> {
1966 struct Scan<'a> {
1967 expr_types: &'a HashMap<ExprId, checker::TypedExpr>,
1968 tys: &'a Types,
1969 constants: HashSet<String>,
1971 found: Option<Span>,
1972 }
1973 impl Scan<'_> {
1974 fn is_instant(&self, e: &Expr) -> bool {
1975 self.expr_types.get(&e.id).is_some_and(|t| {
1976 matches!(&*self.tys.get(t.ty), checker::Ty::Base(BaseType::Instant))
1977 })
1978 }
1979 fn constant(&self, e: &Expr) -> bool {
1980 match &e.kind {
1981 ExprKind::Ident(id) => {
1984 self.constants.contains(&id.name)
1985 || id.name.starts_with(|c: char| c.is_ascii_uppercase())
1986 }
1987 _ => expr_children(e).into_iter().all(|c| self.constant(c)),
1988 }
1989 }
1990 fn without(&mut self, names: Vec<String>, f: impl FnOnce(&mut Self)) {
1992 let saved = self.constants.clone();
1993 for n in &names {
1994 self.constants.remove(n);
1995 }
1996 f(self);
1997 self.constants = saved;
1998 }
1999 fn check(&mut self, pred: &Expr, span: Span) {
2003 if self.found.is_some() {
2004 return;
2005 }
2006 let mut binders = Vec::new();
2007 walk_expr_tree(pred, &mut |e: &Expr| binder_names(e, &mut binders));
2008 let saved = self.constants.clone();
2009 for n in &binders {
2010 self.constants.remove(n);
2011 }
2012 let mut hit = false;
2013 walk_expr_tree(pred, &mut |e: &Expr| {
2014 if let ExprKind::BinOp(
2015 BinOp::Eq | BinOp::NotEq | BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq,
2016 a,
2017 b,
2018 ) = &e.kind
2019 && [a, b]
2020 .into_iter()
2021 .any(|side| self.is_instant(side) && self.constant(side))
2022 {
2023 hit = true;
2024 }
2025 });
2026 self.constants = saved;
2027 if hit {
2028 self.found = Some(span);
2029 }
2030 }
2031 fn block(&mut self, b: &Block) {
2032 for stmt in &b.statements {
2033 match stmt {
2034 Statement::Let(l) => {
2035 if self.constant(&l.value) {
2036 self.constants.insert(l.name.name.clone());
2037 } else {
2038 self.constants.remove(&l.name.name);
2039 }
2040 }
2041 Statement::EffectLet(l) => {
2042 self.constants.remove(&l.name.name);
2043 }
2044 Statement::Expect(x) => self.check(&x.value, x.span),
2045 _ => {}
2046 }
2047 let mut exprs = Vec::new();
2048 statement_exprs(stmt, &mut exprs);
2049 for e in exprs {
2050 self.expr(e);
2051 }
2052 }
2053 self.expr(&b.tail);
2054 }
2055 fn expr(&mut self, e: &Expr) {
2057 match &e.kind {
2058 ExprKind::Block(b) => self.block(b),
2059 ExprKind::If {
2060 cond,
2061 then_block,
2062 else_block,
2063 } => {
2064 self.expr(cond);
2065 let mut names = Vec::new();
2066 walk_expr_tree(cond, &mut |e: &Expr| binder_names(e, &mut names));
2067 self.without(names, |scan| {
2068 scan.block(then_block);
2069 scan.block(else_block);
2070 });
2071 }
2072 ExprKind::Match { discriminant, arms } => {
2073 self.expr(discriminant);
2074 for arm in arms {
2075 let names = arm
2076 .pattern
2077 .bound_names()
2078 .into_iter()
2079 .map(|id| id.name.clone())
2080 .collect();
2081 self.without(names, |scan| {
2082 if let Some(guard) = &arm.guard {
2083 scan.expr(guard);
2084 }
2085 match &arm.body {
2086 MatchBody::Expr(body) => scan.expr(body),
2087 MatchBody::Block(b) => scan.block(b),
2088 }
2089 });
2090 }
2091 }
2092 ExprKind::Lambda(l) => {
2093 let names = l.params.iter().map(|p| p.name.name.clone()).collect();
2094 self.without(names, |scan| scan.expr(&l.body));
2095 }
2096 ExprKind::Expect(inner) => {
2097 self.check(inner, e.span);
2098 self.expr(inner);
2099 }
2100 _ => {
2101 for c in expr_children(e) {
2102 self.expr(c);
2103 }
2104 }
2105 }
2106 }
2107 }
2108 let mut scan = Scan {
2109 expr_types,
2110 tys,
2111 constants: HashSet::new(),
2112 found: None,
2113 };
2114 scan.block(&case.body);
2115 let span = scan.found?;
2116 Some(
2117 CompileError::new(
2118 "bynk.test.remote_clock_dependent",
2119 span,
2120 format!(
2121 "case `\"{}\"` compares an `Instant` that isn't derived from the run, which can pass in process and fail against a real runtime",
2122 case.name
2123 ),
2124 )
2125 .with_note(
2126 "in process the clock starts at the epoch; a real runtime's Workers read wall-clock time. Assert a relation between instants the run produced (`expires > created`) instead of an absolute instant",
2127 ),
2128 )
2129}
2130
2131fn binder_names(e: &Expr, out: &mut Vec<String>) {
2134 match &e.kind {
2135 ExprKind::Is { pattern, .. } => {
2136 out.extend(pattern.bound_names().into_iter().map(|id| id.name.clone()));
2137 }
2138 ExprKind::Lambda(l) => out.extend(l.params.iter().map(|p| p.name.name.clone())),
2139 ExprKind::Match { arms, .. } => {
2140 for arm in arms {
2141 out.extend(
2142 arm.pattern
2143 .bound_names()
2144 .into_iter()
2145 .map(|id| id.name.clone()),
2146 );
2147 }
2148 }
2149 _ => {}
2150 }
2151}
2152
2153fn walk_expr_tree(e: &Expr, f: &mut dyn FnMut(&Expr)) {
2157 f(e);
2158 for c in expr_children(e) {
2159 walk_expr_tree(c, f);
2160 }
2161}
2162
2163fn block_has_advance(block: &Block) -> bool {
2165 fn walk(e: &Expr) -> bool {
2166 match &e.kind {
2167 ExprKind::Block(b) => block_has_advance(b),
2168 ExprKind::If {
2169 cond,
2170 then_block,
2171 else_block,
2172 } => walk(cond) || block_has_advance(then_block) || block_has_advance(else_block),
2173 ExprKind::Match { discriminant, arms } => {
2174 walk(discriminant)
2175 || arms.iter().any(|a| {
2176 a.guard.as_ref().is_some_and(walk)
2177 || match &a.body {
2178 MatchBody::Block(b) => block_has_advance(b),
2179 MatchBody::Expr(x) => walk(x),
2180 }
2181 })
2182 }
2183 _ => expr_children(e).into_iter().any(walk),
2184 }
2185 }
2186 if block
2187 .statements
2188 .iter()
2189 .any(|s| matches!(s, Statement::Advance(_)))
2190 {
2191 return true;
2192 }
2193 let mut exprs = Vec::new();
2194 for s in &block.statements {
2195 statement_exprs(s, &mut exprs);
2196 }
2197 exprs.push(&block.tail);
2198 exprs.into_iter().any(walk)
2199}
2200
2201fn check_unsigned_route_at_system(
2211 case: &Case,
2212 callees: &HashMap<ExprId, checker::Callee>,
2213 services: &HashMap<String, checker::TestServiceSig>,
2214 actors: &HashMap<String, bynk_syntax::ast::ActorDecl>,
2215 errors: &mut Vec<CompileError>,
2216) {
2217 use crate::actors::Scheme;
2218 use bynk_syntax::ast::HandlerKind;
2219 let mut principals: HashMap<ExprId, &str> = HashMap::new();
2221 for b in blocks_deep(&case.body) {
2222 for st in &b.statements {
2223 if let Statement::EffectLet(l) = st
2224 && let Some(p) = &l.principal
2225 {
2226 principals.insert(l.value.id, p.actor.name.as_str());
2227 }
2228 }
2229 }
2230 let unsigned_scheme = |name: &str| -> Option<&'static str> {
2233 let a = actors.get(name)?;
2234 let base = a
2235 .refinement
2236 .as_ref()
2237 .and_then(|r| actors.get(&r.base.name))
2238 .unwrap_or(a);
2239 match Scheme::from_name(base.auth.as_ref()?.name.as_str())? {
2240 Scheme::Oidc => Some("Oidc"),
2241 _ => None,
2242 }
2243 };
2244 let mut found: Vec<(String, String, String, &'static str)> = Vec::new();
2245 for (id, callee) in callees {
2246 let checker::Callee::TestService { service, address } = callee else {
2247 continue;
2248 };
2249 let Some((verb, path)) = address.split_once(' ') else {
2250 continue;
2251 };
2252 let principal = principals.get(id).copied();
2253 if principal == Some("Nobody") || principal.is_some_and(is_bad_signature_name) {
2254 continue;
2255 }
2256 let Some(handler) = services.get(service).and_then(|sig| {
2257 sig.handlers.iter().find(|h| {
2258 matches!(&h.kind, HandlerKind::Http { method, path: p } if method.as_str() == verb && p == path)
2259 })
2260 }) else {
2261 continue;
2262 };
2263 let Some(by) = handler.by_clause.as_ref() else {
2264 continue;
2265 };
2266 let actor = if by.is_sum() {
2272 match principal {
2273 Some(p) if by.actors.iter().any(|a| a.name == p) => p,
2274 Some(_) => continue,
2275 None => by.primary().name.as_str(),
2276 }
2277 } else {
2278 by.primary().name.as_str()
2279 };
2280 if let Some(scheme) = unsigned_scheme(actor) {
2281 found.push((
2282 service.clone(),
2283 format!("{verb}(\"{path}\")"),
2284 actor.to_string(),
2285 scheme,
2286 ));
2287 }
2288 }
2289 found.sort_unstable();
2290 found.dedup();
2291 for (service, route, actor, scheme) in found {
2292 let (code, missing) = (
2293 "bynk.test.oidc_needs_in_process",
2294 "no `Oidc` token issuer yet, so the request reaches the seam with no token",
2295 );
2296 errors.push(
2297 CompileError::new(
2298 code,
2299 case.name_span,
2300 format!(
2301 "case `\"{}\"` calls `{service}.{route}`, whose actor `{actor}` is `{scheme}`-secured, but is a `system`-tier case",
2302 case.name
2303 ),
2304 )
2305 .with_note(format!(
2306 "the `system` driver has {missing} and is refused with a `401`; call the route from a suite `as integration` (or `unit`), where the handler runs in process"
2307 )),
2308 );
2309 }
2310}
2311
2312fn check_faults_tier(
2321 case: &Case,
2322 tier: bynk_syntax::ast::TestTier,
2323 errors: &mut Vec<CompileError>,
2324) {
2325 if tier != bynk_syntax::ast::TestTier::System {
2326 for p in faults_claims(&case.body)
2327 .into_iter()
2328 .filter_map(|f| f.principal.as_ref())
2329 .filter(|p| p.actor.name == "Nobody" || is_bad_signature_name(&p.actor.name))
2330 {
2331 let name = &p.actor.name;
2334 let what = if name == "Nobody" {
2335 "the missing credential"
2336 } else {
2337 "the bad signature"
2338 };
2339 errors.push(
2340 CompileError::new(
2341 "bynk.test.faults_needs_in_process",
2342 p.span,
2343 format!("a fault claim cannot be driven `by {name}`: {what} is rejected only by the `system`-tier auth seam, where a fault is never a throw"),
2344 )
2345 .with_note(format!(
2346 "assert the rejection instead, in a `system` case: `let r <- <call> by {name}` then `expect r is Rejected(Unauthorized)`"
2347 )),
2348 );
2349 }
2350 return;
2351 }
2352 if block_uses_faults(&case.body) {
2353 errors.push(
2354 CompileError::new(
2355 "bynk.test.faults_needs_in_process",
2356 case.name_span,
2357 format!(
2358 "case `\"{}\"` claims a fault with `faults`, but is a `system`-tier case",
2359 case.name
2360 ),
2361 )
2362 .with_note(
2363 "at `system` a handler's fault reaches the case as an error response from the deployed Worker, not a throw; claim the fault at `unit` or `integration`, or assert the response at `system`",
2364 ),
2365 );
2366 }
2367}
2368
2369fn block_uses_nobody(block: &Block) -> bool {
2376 blocks_deep(block).into_iter().any(|b| {
2377 b.statements.iter().any(|s| {
2378 matches!(s, Statement::EffectLet(l)
2379 if l.principal.as_ref().is_some_and(|p| p.actor.name == "Nobody"))
2380 })
2381 })
2382}
2383
2384fn is_bad_signature_name(name: &str) -> bool {
2387 matches!(name, "Forged" | "Stale")
2388}
2389
2390fn check_bad_signature_tier(
2396 case: &Case,
2397 tier: bynk_syntax::ast::TestTier,
2398 errors: &mut Vec<CompileError>,
2399) {
2400 if tier == bynk_syntax::ast::TestTier::System {
2401 return;
2402 }
2403 let mut names: Vec<&str> = blocks_deep(&case.body)
2404 .into_iter()
2405 .flat_map(|b| b.statements.iter())
2406 .filter_map(|s| match s {
2407 Statement::EffectLet(l) => l
2408 .principal
2409 .as_ref()
2410 .map(|p| p.actor.name.as_str())
2411 .filter(|n| is_bad_signature_name(n)),
2412 _ => None,
2413 })
2414 .collect();
2415 names.sort_unstable();
2416 names.dedup();
2417 for name in names {
2418 errors.push(
2419 CompileError::new(
2420 "bynk.test.forged_needs_system",
2421 case.name_span,
2422 format!(
2423 "case `\"{}\"` drives `by {name}` but is a `{}`-tier case",
2424 case.name,
2425 tier.as_str()
2426 ),
2427 )
2428 .with_note(format!(
2429 "`by {name}` sends a bad signature to the real `Signature` seam, which only the `system` driver reaches; in process the seam is skipped. Move the case into a `suite … as system`"
2430 )),
2431 );
2432 }
2433}
2434
2435pub fn register_call_record_types(
2440 resolved: &mut ResolvedCommons,
2441 target_name: &str,
2442 unit_tables: &HashMap<String, UnitTable>,
2443) {
2444 let Some(table) = unit_tables.get(target_name) else {
2445 return;
2446 };
2447 let mut caps: Vec<(&String, &CapabilityDecl)> = table.capabilities.iter().collect();
2450 for (n, d) in flattened_platform_capabilities(unit_tables, resolved) {
2451 if !table.capabilities.contains_key(n) {
2452 caps.push((n, d));
2453 }
2454 }
2455 for (cap_name, decl) in caps {
2456 for op in &decl.ops {
2457 let fields: Vec<RecordField> = op
2458 .params
2459 .iter()
2460 .map(|p| RecordField {
2461 documentation: None,
2462 trivia: Default::default(),
2463 name: p.name.clone(),
2464 type_ref: p.type_ref.clone(),
2465 refinement: None,
2466 init: None,
2467 span: p.span,
2468 })
2469 .collect();
2470 let name = checker::call_record_type_name(cap_name, &op.name.name);
2471 resolved.types.insert(
2472 name.clone(),
2473 Arc::new(TypeDecl {
2474 type_params: Vec::new(),
2475 name: Ident {
2476 name,
2477 span: op.name.span,
2478 },
2479 body: TypeBody::Record(RecordBody {
2480 trailing_comments: Default::default(),
2481 fields,
2482 span: op.name.span,
2483 }),
2484 documentation: None,
2485 span: op.name.span,
2486 trivia: Trivia::default(),
2487 }),
2488 );
2489 }
2490 }
2491}
2492
2493fn target_test_actors(table: Option<&UnitTable>) -> HashMap<String, bynk_syntax::ast::ActorDecl> {
2494 table.map(|t| t.actors.clone()).unwrap_or_default()
2495}
2496
2497pub fn event_publisher(
2504 target: &str,
2505 event: &str,
2506 unit_tables: &HashMap<String, UnitTable>,
2507 consumed: &[String],
2508) -> Result<String, Vec<String>> {
2509 if unit_tables
2510 .get(target)
2511 .is_some_and(|t| t.events.contains_key(event))
2512 {
2513 return Ok(target.to_string());
2514 }
2515 let mut owners: Vec<String> = consumed
2516 .iter()
2517 .filter(|c| c.as_str() != target)
2518 .filter(|c| {
2519 unit_tables
2520 .get(*c)
2521 .is_some_and(|t| t.events.contains_key(event))
2522 })
2523 .cloned()
2524 .collect();
2525 owners.sort();
2526 owners.dedup();
2527 match owners.as_slice() {
2528 [one] => Ok(one.clone()),
2529 _ => Err(owners),
2530 }
2531}
2532
2533fn target_test_services(
2534 target: Option<&str>,
2535 unit_tables: &HashMap<String, UnitTable>,
2536 consumed: &[String],
2537) -> HashMap<String, checker::TestServiceSig> {
2538 use bynk_syntax::ast::ServiceProtocol;
2539 let Some((target, t)) = target.and_then(|n| unit_tables.get(n).map(|t| (n, t))) else {
2540 return HashMap::new();
2541 };
2542 let scope_events: std::collections::BTreeSet<String> = std::iter::once(target)
2545 .chain(consumed.iter().map(String::as_str))
2546 .filter_map(|u| unit_tables.get(u))
2547 .flat_map(|u| u.events.keys().cloned())
2548 .collect();
2549 t.services
2550 .iter()
2551 .map(|(name, decl)| {
2552 let protocol = match &decl.protocol {
2553 ServiceProtocol::Call => None,
2554 ServiceProtocol::Http => Some("http".to_string()),
2555 ServiceProtocol::Cron => Some("cron".to_string()),
2556 ServiceProtocol::Queue { .. } => Some("queue".to_string()),
2557 ServiceProtocol::WebSocket { .. } => Some("websocket".to_string()),
2558 ServiceProtocol::Events { .. } => Some("events".to_string()),
2559 };
2560 let handlers = decl
2561 .handlers
2562 .iter()
2563 .map(|h| checker::TestHandler {
2564 kind: h.kind.clone(),
2565 params: h.params.clone(),
2566 by_clause: h.by_clause.clone(),
2567 return_type: h.return_type.clone(),
2568 span: h.span,
2569 })
2570 .collect();
2571 let (known_events, event_publisher) = match &decl.protocol {
2572 ServiceProtocol::Events {
2573 event_type: TypeRef::Named(e),
2574 ..
2575 } => (
2576 scope_events.clone(),
2577 Some(event_publisher(target, &e.name, unit_tables, consumed)),
2578 ),
2579 _ => (Default::default(), None),
2580 };
2581 (
2582 name.clone(),
2583 checker::TestServiceSig {
2584 protocol,
2585 handlers,
2586 known_events,
2587 event_publisher,
2588 },
2589 )
2590 })
2591 .collect()
2592}
2593
2594fn flattened_platform_capabilities<'a>(
2598 unit_tables: &'a HashMap<String, UnitTable>,
2599 resolved: &ResolvedCommons,
2600) -> Vec<(&'a String, &'a CapabilityDecl)> {
2601 let mut out: Vec<_> = resolved
2602 .cross_context
2603 .flattened_caps
2604 .iter()
2605 .filter_map(|(cap, owner)| {
2606 let t = unit_tables.get(owner)?;
2607 if t.kind != Some(UnitKind::Adapter) {
2608 return None;
2609 }
2610 t.capabilities.get_key_value(cap)
2611 })
2612 .collect();
2613 out.sort_by_key(|(cap, _)| cap.as_str());
2614 out
2615}
2616
2617fn add_consumed_adapter_capabilities(
2623 map: &mut HashMap<String, checker::CapabilityInfo>,
2624 unit_tables: &HashMap<String, UnitTable>,
2625 resolved: &ResolvedCommons,
2626 tys: &Arc<Types>,
2627) {
2628 for (name, decl) in flattened_platform_capabilities(unit_tables, resolved) {
2629 if map.contains_key(name) {
2630 continue;
2631 }
2632 let ops = decl
2633 .ops
2634 .iter()
2635 .map(|op| build_capability_op_info(op, &resolved.types, tys))
2636 .collect();
2637 map.insert(
2638 name.clone(),
2639 checker::CapabilityInfo {
2640 name: name.clone(),
2641 ops,
2642 derived_from: None,
2643 },
2644 );
2645 }
2646}
2647
2648#[allow(clippy::too_many_arguments)]
2661pub fn typecheck_case_body(
2662 target_name: &str,
2663 body: &Block,
2664 unit_span: Span,
2665 unit_tables: &HashMap<String, UnitTable>,
2666 resolved: &ResolvedCommons,
2667 errors: &mut Vec<CompileError>,
2668 refs: &mut RefSink,
2669 initial_scope: HashMap<String, checker::TyId>,
2672 tys: &Arc<Types>,
2673) -> (
2674 HashMap<ExprId, checker::TypedExpr>,
2675 HashMap<ExprId, checker::Callee>,
2676) {
2677 let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
2678 let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
2679 let synthetic_return = TypeRef::Effect(
2683 Box::new(TypeRef::Result(
2684 Box::new(TypeRef::Unit(unit_span)),
2685 Box::new(TypeRef::ValidationError(unit_span)),
2686 unit_span,
2687 )),
2688 unit_span,
2689 );
2690
2691 let mut capability_info_map: HashMap<String, checker::CapabilityInfo> = HashMap::new();
2694 if let Some(table) = unit_tables.get(target_name) {
2695 for (name, decl) in &table.capabilities {
2696 let ops = decl
2697 .ops
2698 .iter()
2699 .map(|op| build_capability_op_info(op, &resolved.types, tys))
2700 .collect();
2701 capability_info_map.insert(
2702 name.clone(),
2703 checker::CapabilityInfo {
2704 name: name.clone(),
2705 ops,
2706 derived_from: None,
2707 },
2708 );
2709 }
2710 }
2711 add_consumed_adapter_capabilities(&mut capability_info_map, unit_tables, resolved, tys);
2712
2713 let given_declared: Vec<String> = capability_info_map.keys().cloned().collect();
2717
2718 let return_ty = checker::resolve_type_ref(&synthetic_return, &resolved.types, tys).unwrap();
2719 let return_ty_span = unit_span;
2720 let mut no_hints = HintSink::new();
2722 let mut no_locals = LocalsSink::new();
2723 let mut no_requirements = RequirementSink::new();
2725 let _ = checker::check_body(
2726 resolved,
2727 body,
2728 return_ty,
2729 return_ty_span,
2730 initial_scope,
2731 checker::CapabilityCtx {
2732 capabilities: capability_info_map.clone(),
2733 declared_capabilities: capability_info_map,
2734 given_remaining: given_declared.iter().cloned().collect(),
2735 given_used: HashSet::new(),
2736 given_entries: Vec::new(),
2737 given_anchor: None,
2738 },
2739 target_test_services(
2740 Some(target_name),
2741 unit_tables,
2742 &resolved.cross_context.consumed_contexts,
2743 ),
2744 target_test_actors(unit_tables.get(target_name)),
2745 None,
2746 checker::CheckSinks {
2747 tys,
2748 expr_types: &mut expr_types,
2749 errors,
2750 refs,
2751 hints: &mut no_hints,
2752 locals: &mut no_locals,
2753 requirements: &mut no_requirements,
2754 callees: &mut callees,
2755 },
2756 );
2757 (expr_types, callees)
2758}
2759
2760#[allow(clippy::too_many_arguments)]
2761fn check_test_case_body(
2762 target_name: &str,
2763 target_kind: UnitKind,
2764 case: &Case,
2765 unit_tables: &HashMap<String, UnitTable>,
2766 unit_uses: &HashMap<String, Vec<String>>,
2767 unit_consumes: &HashMap<String, Vec<String>>,
2768 unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2769 errors: &mut Vec<CompileError>,
2770 refs: &mut RefSink,
2771 tys: &Arc<Types>,
2772) {
2773 let Some((mut resolved, _)) = build_privileged_resolved(
2774 target_name,
2775 unit_tables,
2776 unit_uses,
2777 unit_consumes,
2778 unit_consumes_aliases,
2779 ) else {
2780 return;
2781 };
2782 register_call_record_types(&mut resolved, target_name, unit_tables);
2783 let _ = target_kind;
2784 let _ = typecheck_case_body(
2785 target_name,
2786 &case.body,
2787 case.span,
2788 unit_tables,
2789 &resolved,
2790 errors,
2791 refs,
2792 HashMap::new(),
2793 tys,
2794 );
2795 check_restated_contract(&case.body, &resolved, errors);
2806}
2807
2808fn check_restated_contract(
2815 body: &Block,
2816 resolved: &ResolvedCommons,
2817 errors: &mut Vec<CompileError>,
2818) {
2819 let mut bound: HashMap<String, (&FnDecl, &[Expr])> = HashMap::new();
2822 for stmt in &body.statements {
2823 let (name, value) = match stmt {
2824 Statement::Let(l) | Statement::EffectLet(l) => (&l.name.name, &l.value),
2825 _ => continue,
2826 };
2827 if let ExprKind::Call {
2828 name: callee, args, ..
2829 } = &value.kind
2830 && let Some(f) = resolved.fns.get(&callee.name)
2831 && matches!(&f.name, FnName::Free(_))
2832 && !f.ensures.is_empty()
2833 && f.params.len() == args.len()
2834 {
2835 bound.insert(name.clone(), (f, args.as_slice()));
2836 }
2837 }
2838 if bound.is_empty() {
2839 return;
2840 }
2841 for stmt in &body.statements {
2842 let Statement::Expect(e) = stmt else { continue };
2843 for (result_name, (f, args)) in &bound {
2844 let result_ident = Expr {
2846 id: ExprId::SYNTHETIC,
2847 kind: ExprKind::Ident(Ident {
2848 name: result_name.clone(),
2849 span: e.span,
2850 }),
2851 span: e.span,
2852 };
2853 let mut subst: HashMap<&str, &Expr> = HashMap::new();
2854 subst.insert("result", &result_ident);
2855 for (p, a) in f.params.iter().zip(args.iter()) {
2856 subst.insert(p.name.name.as_str(), a);
2857 }
2858 for c in &f.ensures {
2859 if expr_alpha_eq_subst(&c.predicate, &e.value, &subst) {
2860 let FnName::Free(fname) = &f.name else {
2861 continue;
2862 };
2863 errors.push(
2864 CompileError::new(
2865 "bynk.contract.restated_by_test",
2866 e.span,
2867 format!(
2868 "this `expect` restates the `ensures {}` contract of `{}`, which is already checked at every call and by the runner",
2869 c.name.name, fname.name
2870 ),
2871 )
2872 .with_note(
2873 "a contract is checked everywhere for free — delete the restating test, or keep a `case` only for a specific witnessed value",
2874 ),
2875 );
2876 break;
2877 }
2878 }
2879 }
2880 }
2881}
2882
2883fn expr_alpha_eq_subst(pattern: &Expr, actual: &Expr, subst: &HashMap<&str, &Expr>) -> bool {
2889 if let ExprKind::Ident(id) = &pattern.kind
2890 && let Some(replacement) = subst.get(id.name.as_str())
2891 {
2892 return expr_struct_eq(replacement, actual);
2893 }
2894 match (&pattern.kind, &actual.kind) {
2895 (ExprKind::Ident(a), ExprKind::Ident(b)) => a.name == b.name,
2896 (ExprKind::IntLit { value: a, .. }, ExprKind::IntLit { value: b, .. }) => a == b,
2897 (ExprKind::BoolLit(a), ExprKind::BoolLit(b)) => a == b,
2898 (ExprKind::StrLit(a), ExprKind::StrLit(b)) => a == b,
2899 (ExprKind::Paren(a), _) => expr_alpha_eq_subst(a, actual, subst),
2900 (_, ExprKind::Paren(b)) => expr_alpha_eq_subst(pattern, b, subst),
2901 (ExprKind::BinOp(oa, la, ra), ExprKind::BinOp(ob, lb, rb)) => {
2902 oa == ob && expr_alpha_eq_subst(la, lb, subst) && expr_alpha_eq_subst(ra, rb, subst)
2903 }
2904 (ExprKind::UnaryOp(oa, a), ExprKind::UnaryOp(ob, b)) => {
2905 oa == ob && expr_alpha_eq_subst(a, b, subst)
2906 }
2907 (
2908 ExprKind::MethodCall {
2909 receiver: ra,
2910 method: ma,
2911 args: aa,
2912 ..
2913 },
2914 ExprKind::MethodCall {
2915 receiver: rb,
2916 method: mb,
2917 args: ab,
2918 ..
2919 },
2920 ) => {
2921 ma.name == mb.name
2922 && aa.len() == ab.len()
2923 && expr_alpha_eq_subst(ra, rb, subst)
2924 && aa
2925 .iter()
2926 .zip(ab.iter())
2927 .all(|(x, y)| expr_alpha_eq_subst(x, y, subst))
2928 }
2929 _ => false,
2930 }
2931}
2932
2933fn expr_struct_eq(a: &Expr, b: &Expr) -> bool {
2936 match (&a.kind, &b.kind) {
2937 (ExprKind::Ident(x), ExprKind::Ident(y)) => x.name == y.name,
2938 (ExprKind::IntLit { value: x, .. }, ExprKind::IntLit { value: y, .. }) => x == y,
2939 (ExprKind::BoolLit(x), ExprKind::BoolLit(y)) => x == y,
2940 (ExprKind::StrLit(x), ExprKind::StrLit(y)) => x == y,
2941 (ExprKind::Paren(x), _) => expr_struct_eq(x, b),
2942 (_, ExprKind::Paren(y)) => expr_struct_eq(a, y),
2943 (ExprKind::BinOp(oa, la, ra), ExprKind::BinOp(ob, lb, rb)) => {
2944 oa == ob && expr_struct_eq(la, lb) && expr_struct_eq(ra, rb)
2945 }
2946 (ExprKind::UnaryOp(oa, x), ExprKind::UnaryOp(ob, y)) => oa == ob && expr_struct_eq(x, y),
2947 (
2948 ExprKind::MethodCall {
2949 receiver: ra,
2950 method: ma,
2951 args: aa,
2952 ..
2953 },
2954 ExprKind::MethodCall {
2955 receiver: rb,
2956 method: mb,
2957 args: ab,
2958 ..
2959 },
2960 ) => {
2961 ma.name == mb.name
2962 && aa.len() == ab.len()
2963 && expr_struct_eq(ra, rb)
2964 && aa.iter().zip(ab.iter()).all(|(x, y)| expr_struct_eq(x, y))
2965 }
2966 _ => false,
2967 }
2968}
2969
2970pub const PROP_GEN_DEPTH: u32 = 12;
2973
2974pub fn prop_binding_generable(
2979 ty: checker::TyId,
2980 types: &HashMap<String, Arc<TypeDecl>>,
2981 depth: u32,
2982 tys: &Arc<Types>,
2983) -> bool {
2984 if depth == 0 {
2985 return false;
2986 }
2987 match &*tys.get(ty) {
2988 checker::Ty::Base(_) => true,
2989 checker::Ty::Named { name, .. } => {
2990 let Some(decl) = types.get(name) else {
2991 return false;
2992 };
2993 match &decl.body {
2994 TypeBody::Refined { refinement, .. } | TypeBody::Opaque { refinement, .. } => {
2995 !refinement.as_ref().is_some_and(|r| {
2996 r.predicates
2997 .iter()
2998 .any(|p| matches!(p.kind, PredKind::Matches(_)))
2999 })
3000 }
3001 TypeBody::Sum(s) => s.variants.first().is_some_and(|v| {
3002 v.payload.iter().all(|f| {
3003 checker::resolve_type_ref(&f.type_ref, types, tys)
3004 .is_some_and(|t| prop_binding_generable(t, types, depth - 1, tys))
3005 })
3006 }),
3007 TypeBody::Record(r) => r.fields.iter().all(|f| {
3008 checker::resolve_type_ref(&f.type_ref, types, tys)
3009 .is_some_and(|t| prop_binding_generable(t, types, depth - 1, tys))
3010 }),
3011 }
3012 }
3013 _ => false,
3014 }
3015}
3016
3017fn named_refinement<'a>(
3020 ty: checker::TyId,
3021 types: &'a HashMap<String, Arc<TypeDecl>>,
3022 tys: &Arc<Types>,
3023) -> Option<&'a Refinement> {
3024 let node = tys.get(ty);
3025 let checker::Ty::Named { name, .. } = &*node else {
3026 return None;
3027 };
3028 match &types.get(name)?.body {
3029 TypeBody::Refined { refinement, .. } | TypeBody::Opaque { refinement, .. } => {
3030 refinement.as_ref()
3031 }
3032 _ => None,
3033 }
3034}
3035
3036fn predicate_restates_refinement(pred: &Expr, bound_var: &str, refinement: &Refinement) -> bool {
3042 let ExprKind::BinOp(op, lhs, rhs) = &pred.kind else {
3043 return false;
3044 };
3045 let ExprKind::Ident(id) = &lhs.kind else {
3047 return false;
3048 };
3049 if id.name != bound_var {
3050 return false;
3051 }
3052 let ExprKind::IntLit { value: n, .. } = &rhs.kind else {
3053 return false;
3054 };
3055 let n = *n;
3056 let positive = refinement
3057 .predicates
3058 .iter()
3059 .any(|p| matches!(p.kind, PredKind::Positive));
3060 let non_negative = refinement
3061 .predicates
3062 .iter()
3063 .any(|p| matches!(p.kind, PredKind::NonNegative));
3064 match op {
3065 BinOp::Gt if n == 0 => positive,
3067 BinOp::GtEq if n == 1 => positive,
3068 BinOp::GtEq if n == 0 => non_negative,
3070 _ => false,
3071 }
3072}
3073
3074#[derive(Clone, Copy)]
3077enum HistoryRestate {
3078 Invariant,
3080 Transition,
3082}
3083
3084fn as_new_field<'a>(e: &'a Expr, s: &str) -> Option<&'a str> {
3087 let ExprKind::FieldAccess { receiver, field } = &e.kind else {
3088 return None;
3089 };
3090 let ExprKind::FieldAccess {
3091 receiver: inner,
3092 field: which,
3093 } = &receiver.kind
3094 else {
3095 return None;
3096 };
3097 let ExprKind::Ident(id) = &inner.kind else {
3098 return None;
3099 };
3100 (id.name == s && which.name == "new").then_some(field.name.as_str())
3101}
3102
3103fn as_step_root<'a>(e: &'a Expr, s: &str) -> Option<&'a str> {
3106 let ExprKind::FieldAccess { receiver, field } = &e.kind else {
3107 return None;
3108 };
3109 let ExprKind::Ident(id) = &receiver.kind else {
3110 return None;
3111 };
3112 (id.name == s && (field.name == "old" || field.name == "new")).then_some(field.name.as_str())
3113}
3114
3115fn history_pred_matches(body: &Expr, s: &str, decl: &Expr, mode: HistoryRestate) -> bool {
3120 match mode {
3122 HistoryRestate::Invariant => {
3123 if let (Some(f), ExprKind::Ident(id)) = (as_new_field(body, s), &decl.kind) {
3124 return f == id.name;
3125 }
3126 }
3127 HistoryRestate::Transition => {
3128 if let (Some(root), ExprKind::Ident(id)) = (as_step_root(body, s), &decl.kind) {
3129 return root == id.name;
3130 }
3131 }
3132 }
3133 match (&body.kind, &decl.kind) {
3134 (ExprKind::Paren(x), _) => history_pred_matches(x, s, decl, mode),
3135 (_, ExprKind::Paren(y)) => history_pred_matches(body, s, y, mode),
3136 (ExprKind::IntLit { value: x, .. }, ExprKind::IntLit { value: y, .. }) => x == y,
3137 (ExprKind::BoolLit(x), ExprKind::BoolLit(y)) => x == y,
3138 (ExprKind::StrLit(x), ExprKind::StrLit(y)) => x == y,
3139 (ExprKind::Ident(x), ExprKind::Ident(y)) => x.name == y.name,
3140 (ExprKind::None, ExprKind::None) => true,
3141 (ExprKind::Some(x), ExprKind::Some(y)) => history_pred_matches(x, s, y, mode),
3142 (ExprKind::UnaryOp(o1, x), ExprKind::UnaryOp(o2, y)) => {
3143 o1 == o2 && history_pred_matches(x, s, y, mode)
3144 }
3145 (
3148 ExprKind::Is {
3149 value: v1,
3150 pattern: p1,
3151 },
3152 ExprKind::Is {
3153 value: v2,
3154 pattern: p2,
3155 },
3156 ) => history_pattern_matches(p1, p2) && history_pred_matches(v1, s, v2, mode),
3157 (ExprKind::BinOp(o1, l1, r1), ExprKind::BinOp(o2, l2, r2)) => {
3158 o1 == o2
3159 && history_pred_matches(l1, s, l2, mode)
3160 && history_pred_matches(r1, s, r2, mode)
3161 }
3162 (
3163 ExprKind::FieldAccess {
3164 receiver: r1,
3165 field: f1,
3166 },
3167 ExprKind::FieldAccess {
3168 receiver: r2,
3169 field: f2,
3170 },
3171 ) => f1.name == f2.name && history_pred_matches(r1, s, r2, mode),
3172 (
3173 ExprKind::MethodCall {
3174 receiver: r1,
3175 method: m1,
3176 args: a1,
3177 ..
3178 },
3179 ExprKind::MethodCall {
3180 receiver: r2,
3181 method: m2,
3182 args: a2,
3183 ..
3184 },
3185 ) => {
3186 m1.name == m2.name
3187 && a1.len() == a2.len()
3188 && history_pred_matches(r1, s, r2, mode)
3189 && a1
3190 .iter()
3191 .zip(a2)
3192 .all(|(x, y)| history_pred_matches(x, s, y, mode))
3193 }
3194 (
3195 ExprKind::Call {
3196 name: n1, args: a1, ..
3197 },
3198 ExprKind::Call {
3199 name: n2, args: a2, ..
3200 },
3201 ) => {
3202 n1.name == n2.name
3203 && a1.len() == a2.len()
3204 && a1
3205 .iter()
3206 .zip(a2)
3207 .all(|(x, y)| history_pred_matches(x, s, y, mode))
3208 }
3209 _ => false,
3210 }
3211}
3212
3213fn history_pattern_matches(a: &Pattern, b: &Pattern) -> bool {
3218 match (a, b) {
3219 (Pattern::Literal { value: x, .. }, Pattern::Literal { value: y, .. }) => x == y,
3220 (
3221 Pattern::Variant {
3222 type_name: t1,
3223 variant: v1,
3224 bindings: b1,
3225 ..
3226 },
3227 Pattern::Variant {
3228 type_name: t2,
3229 variant: v2,
3230 bindings: b2,
3231 ..
3232 },
3233 ) => {
3234 b1.is_empty()
3235 && b2.is_empty()
3236 && v1.name == v2.name
3237 && t1.as_ref().map(|t| &t.name) == t2.as_ref().map(|t| &t.name)
3238 }
3239 _ => false,
3240 }
3241}
3242
3243fn history_restates_invariant(prop: &PropertyDecl, run_var: &str, agent: &AgentDecl) -> bool {
3250 let [stmt] = prop.forall.body.statements.as_slice() else {
3251 return false;
3252 };
3253 let Statement::Expect(e) = stmt else {
3254 return false;
3255 };
3256 let ExprKind::MethodCall {
3258 receiver,
3259 method,
3260 args,
3261 ..
3262 } = &e.value.kind
3263 else {
3264 return false;
3265 };
3266 if method.name != "all" && method.name != "any" {
3267 return false;
3268 }
3269 let ExprKind::Ident(recv) = &receiver.kind else {
3270 return false;
3271 };
3272 if recv.name != run_var {
3273 return false;
3274 }
3275 let [arg] = args.as_slice() else {
3276 return false;
3277 };
3278 let ExprKind::Lambda(lam) = &arg.kind else {
3279 return false;
3280 };
3281 let [param] = lam.params.as_slice() else {
3282 return false;
3283 };
3284 let s = ¶m.name.name;
3285 agent
3286 .invariants
3287 .iter()
3288 .any(|inv| history_pred_matches(&lam.body, s, &inv.predicate, HistoryRestate::Invariant))
3289 || agent
3290 .transitions
3291 .iter()
3292 .any(|tr| history_pred_matches(&lam.body, s, &tr.predicate, HistoryRestate::Transition))
3293}
3294
3295fn history_call_type_name(agent: &str) -> String {
3299 format!("__History_{agent}_Call")
3300}
3301fn history_step_type_name(agent: &str) -> String {
3302 format!("__History_{agent}_Step")
3303}
3304fn history_state_type_name(agent: &str) -> String {
3305 format!("__History_{agent}_State")
3306}
3307
3308pub fn history_variant_name(handler: &str) -> String {
3312 let mut chars = handler.chars();
3313 match chars.next() {
3314 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3315 None => handler.to_string(),
3316 }
3317}
3318
3319pub fn history_handlers(agent: &AgentDecl) -> Vec<&Handler> {
3323 agent
3324 .handlers
3325 .iter()
3326 .filter(|h| matches!(h.kind, HandlerKind::Call) && h.method_name.is_some())
3327 .collect()
3328}
3329
3330pub fn check_history_binding(
3338 inner: &TypeRef,
3339 span: Span,
3340 resolved: &mut ResolvedCommons,
3341 refs: &mut RefSink,
3342 tys: &Arc<Types>,
3343) -> Result<checker::Ty, CompileError> {
3344 let TypeRef::Named(agent_id) = inner else {
3347 return Err(CompileError::new(
3348 "bynk.history.not_an_agent",
3349 span,
3350 format!(
3351 "`for all` cannot generate `History[{}]` — only an agent has handlers to sequence",
3352 ts_type_ref_display(inner)
3353 ),
3354 )
3355 .with_note("generate a driven call-history over an agent: `for all run: History[Agent]`"));
3356 };
3357 let Some(agent) = resolved.agents.get(&agent_id.name).cloned() else {
3358 return Err(CompileError::new(
3359 "bynk.history.not_an_agent",
3360 span,
3361 format!(
3362 "`for all run: History[{}]` names `{}`, which is not an agent in scope",
3363 agent_id.name, agent_id.name
3364 ),
3365 )
3366 .with_note(
3367 "only an agent (with handlers and reachable state) can be driven as a history",
3368 ));
3369 };
3370 refs.record(agent_id.span, SymbolKind::Type, &agent_id.name);
3371
3372 let handlers = history_handlers(&agent);
3373 for h in &handlers {
3377 for p in &h.params {
3378 let generable = checker::resolve_type_ref(&p.type_ref, &resolved.types, tys)
3379 .is_some_and(|t| prop_binding_generable(t, &resolved.types, PROP_GEN_DEPTH, tys));
3380 if !generable {
3381 return Err(CompileError::new(
3382 "bynk.history.not_generable",
3383 span,
3384 format!(
3385 "`History[{}]` cannot be driven — handler `{}`'s parameter `{}: {}` is not generable (e.g. a `Matches` refinement)",
3386 agent_id.name,
3387 h.method_name.as_ref().map(|m| m.name.as_str()).unwrap_or(""),
3388 p.name.name,
3389 ts_type_ref_display(&p.type_ref),
3390 ),
3391 )
3392 .with_note(
3393 "every handler parameter must be refinement-generable for the run to be seeded",
3394 ));
3395 }
3396 }
3397 }
3398
3399 let state_name = history_state_type_name(&agent_id.name);
3404 let call_name = history_call_type_name(&agent_id.name);
3405 let step_name = history_step_type_name(&agent_id.name);
3406
3407 let state_fields: Vec<RecordField> = agent
3410 .store_fields
3411 .iter()
3412 .filter(|f| f.kind.head.name == "Cell" && f.kind.args.len() == 1)
3413 .map(|f| RecordField {
3414 documentation: None,
3415 trivia: Default::default(),
3416 name: f.name.clone(),
3417 type_ref: f.kind.args[0].clone(),
3418 refinement: None,
3419 init: None,
3420 span: f.span,
3421 })
3422 .collect();
3423 resolved.types.insert(
3424 state_name.clone(),
3425 Arc::new(TypeDecl {
3426 type_params: Vec::new(),
3427 name: Ident {
3428 name: state_name.clone(),
3429 span,
3430 },
3431 body: TypeBody::Record(RecordBody {
3432 trailing_comments: Default::default(),
3433 fields: state_fields,
3434 span,
3435 }),
3436 documentation: None,
3437 span,
3438 trivia: Trivia::default(),
3439 }),
3440 );
3441
3442 let variants: Vec<Variant> = handlers
3445 .iter()
3446 .map(|h| {
3447 let hname = h.method_name.as_ref().expect("call handler has a name");
3448 Variant {
3449 documentation: None,
3450 trivia: Default::default(),
3451 name: Ident {
3452 name: history_variant_name(&hname.name),
3453 span: hname.span,
3454 },
3455 payload: h
3456 .params
3457 .iter()
3458 .map(|p| VariantField {
3459 documentation: None,
3460 name: p.name.clone(),
3461 type_ref: p.type_ref.clone(),
3462 span: p.span,
3463 })
3464 .collect(),
3465 span: hname.span,
3466 }
3467 })
3468 .collect();
3469 resolved.types.insert(
3470 call_name.clone(),
3471 Arc::new(TypeDecl {
3472 type_params: Vec::new(),
3473 name: Ident {
3474 name: call_name.clone(),
3475 span,
3476 },
3477 body: TypeBody::Sum(SumBody {
3478 trailing_comments: Default::default(),
3479 variants,
3480 embeds: Vec::new(),
3481 span,
3482 }),
3483 documentation: None,
3484 span,
3485 trivia: Trivia::default(),
3486 }),
3487 );
3488
3489 let step_fields = vec![
3492 RecordField {
3493 documentation: None,
3494 trivia: Default::default(),
3495 name: Ident {
3496 name: "call".to_string(),
3497 span,
3498 },
3499 type_ref: TypeRef::Named(Ident {
3500 name: call_name.clone(),
3501 span,
3502 }),
3503 refinement: None,
3504 init: None,
3505 span,
3506 },
3507 RecordField {
3508 documentation: None,
3509 trivia: Default::default(),
3510 name: Ident {
3511 name: "accepted".to_string(),
3512 span,
3513 },
3514 type_ref: TypeRef::Base(BaseType::Bool, span),
3515 refinement: None,
3516 init: None,
3517 span,
3518 },
3519 RecordField {
3520 documentation: None,
3521 trivia: Default::default(),
3522 name: Ident {
3523 name: "old".to_string(),
3524 span,
3525 },
3526 type_ref: TypeRef::Named(Ident {
3527 name: state_name.clone(),
3528 span,
3529 }),
3530 refinement: None,
3531 init: None,
3532 span,
3533 },
3534 RecordField {
3535 documentation: None,
3536 trivia: Default::default(),
3537 name: Ident {
3538 name: "new".to_string(),
3539 span,
3540 },
3541 type_ref: TypeRef::Named(Ident {
3542 name: state_name.clone(),
3543 span,
3544 }),
3545 refinement: None,
3546 init: None,
3547 span,
3548 },
3549 ];
3550 resolved.types.insert(
3551 step_name.clone(),
3552 Arc::new(TypeDecl {
3553 type_params: Vec::new(),
3554 name: Ident {
3555 name: step_name.clone(),
3556 span,
3557 },
3558 body: TypeBody::Record(RecordBody {
3559 trailing_comments: Default::default(),
3560 fields: step_fields,
3561 span,
3562 }),
3563 documentation: None,
3564 span,
3565 trivia: Trivia::default(),
3566 }),
3567 );
3568
3569 Ok(checker::Ty::List(tys.intern(checker::Ty::Named {
3570 name: step_name,
3571 kind: checker::NamedKind::Record,
3572 args: Vec::new(),
3573 })))
3574}
3575
3576#[allow(clippy::too_many_arguments)]
3584fn check_property_body(
3585 target_name: &str,
3586 target_kind: UnitKind,
3587 prop: &PropertyDecl,
3588 unit_tables: &HashMap<String, UnitTable>,
3589 unit_uses: &HashMap<String, Vec<String>>,
3590 unit_consumes: &HashMap<String, Vec<String>>,
3591 unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
3592 errors: &mut Vec<CompileError>,
3593 refs: &mut RefSink,
3594 tys: &Arc<Types>,
3595) {
3596 let Some((mut resolved, _)) = build_privileged_resolved(
3597 target_name,
3598 unit_tables,
3599 unit_uses,
3600 unit_consumes,
3601 unit_consumes_aliases,
3602 ) else {
3603 return;
3604 };
3605 register_call_record_types(&mut resolved, target_name, unit_tables);
3606 let _ = target_kind;
3607
3608 let mut binding_scope: HashMap<String, checker::TyId> = HashMap::new();
3610 let mut binding_types: Vec<(String, Option<checker::TyId>)> = Vec::new();
3611 let mut history_binding: Option<(String, AgentDecl)> = None;
3614 for b in &prop.forall.bindings {
3615 if let TypeRef::History(inner, hspan) = &b.type_ref {
3618 match check_history_binding(inner, *hspan, &mut resolved, refs, tys) {
3619 Ok(step_ty) => {
3620 if let TypeRef::Named(agent_id) = &**inner
3621 && let Some(agent) = resolved.agents.get(&agent_id.name)
3622 {
3623 history_binding = Some((b.name.name.clone(), agent.clone()));
3624 }
3625 binding_scope.insert(b.name.name.clone(), tys.intern(step_ty.clone()));
3626 binding_types.push((b.name.name.clone(), Some(tys.intern(step_ty))));
3627 }
3628 Err(err) => {
3629 errors.push(err);
3630 binding_types.push((b.name.name.clone(), None));
3631 binding_scope.insert(b.name.name.clone(), tys.intern(checker::Ty::Error));
3634 }
3635 }
3636 continue;
3637 }
3638 if let TypeRef::Named(id) = &b.type_ref
3641 && resolved.agents.contains_key(&id.name)
3642 {
3643 errors.push(
3644 CompileError::new(
3645 "bynk.val.agent_not_generable",
3646 b.type_ref.span(),
3647 format!(
3648 "`for all {}: {}` cannot generate an agent — a fabricated agent state need not be reachable",
3649 b.name.name, id.name
3650 ),
3651 )
3652 .with_note(
3653 "generate behaviour over an agent via handler sequences (the history rung), not fabricated states",
3654 ),
3655 );
3656 binding_types.push((b.name.name.clone(), None));
3657 binding_scope.insert(b.name.name.clone(), tys.intern(checker::Ty::Error));
3660 continue;
3661 }
3662 let ty = match checker::resolve_type_ref(&b.type_ref, &resolved.types, tys) {
3663 Some(t) => {
3664 record_type_refs_in_property(&b.type_ref, &resolved, refs);
3665 t
3666 }
3667 None => {
3668 errors.push(CompileError::new(
3669 "bynk.val.unknown_type",
3670 b.type_ref.span(),
3671 format!(
3672 "`for all {}: {}` names a type that does not resolve",
3673 b.name.name,
3674 ts_type_ref_display(&b.type_ref)
3675 ),
3676 ));
3677 binding_types.push((b.name.name.clone(), None));
3678 binding_scope.insert(b.name.name.clone(), tys.intern(checker::Ty::Error));
3681 continue;
3682 }
3683 };
3684 if !prop_binding_generable(ty, &resolved.types, PROP_GEN_DEPTH, tys) {
3685 errors.push(
3686 CompileError::new(
3687 "bynk.val.needs_pin",
3688 b.type_ref.span(),
3689 format!(
3690 "`for all {}: {}` cannot generate a value (e.g. a `Matches` refinement); a property cannot bind it",
3691 b.name.name,
3692 ts_type_ref_display(&b.type_ref)
3693 ),
3694 )
3695 .with_note("supply the witness in a `case` with a pinned `Val[T](...)` instead"),
3696 );
3697 }
3698 binding_scope.insert(b.name.name.clone(), ty);
3699 binding_types.push((b.name.name.clone(), Some(ty)));
3700 }
3701
3702 let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
3705 let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
3706 let unit_span = prop.span;
3707 let synthetic_return = TypeRef::Effect(
3708 Box::new(TypeRef::Result(
3709 Box::new(TypeRef::Unit(unit_span)),
3710 Box::new(TypeRef::ValidationError(unit_span)),
3711 unit_span,
3712 )),
3713 unit_span,
3714 );
3715 let mut capability_info_map: HashMap<String, checker::CapabilityInfo> = HashMap::new();
3716 if let Some(table) = unit_tables.get(target_name) {
3717 for (name, decl) in &table.capabilities {
3718 let ops = decl
3719 .ops
3720 .iter()
3721 .map(|op| build_capability_op_info(op, &resolved.types, tys))
3722 .collect();
3723 capability_info_map.insert(
3724 name.clone(),
3725 checker::CapabilityInfo {
3726 name: name.clone(),
3727 ops,
3728 derived_from: None,
3729 },
3730 );
3731 }
3732 }
3733 add_consumed_adapter_capabilities(&mut capability_info_map, unit_tables, &resolved, tys);
3734 let given_declared: Vec<String> = capability_info_map.keys().cloned().collect();
3735 let return_ty = checker::resolve_type_ref(&synthetic_return, &resolved.types, tys).unwrap();
3736 let return_ty_span = prop.span;
3737 let mut no_hints = HintSink::new();
3738 let mut no_locals = LocalsSink::new();
3739 let mut no_requirements = RequirementSink::new();
3740 let _ = checker::check_body(
3744 &resolved,
3745 &prop.forall.body,
3746 return_ty,
3747 return_ty_span,
3748 binding_scope,
3749 checker::CapabilityCtx {
3750 capabilities: capability_info_map.clone(),
3751 declared_capabilities: capability_info_map,
3752 given_remaining: given_declared.iter().cloned().collect(),
3753 given_used: HashSet::new(),
3754 given_entries: Vec::new(),
3755 given_anchor: None,
3756 },
3757 target_test_services(
3758 Some(target_name),
3759 unit_tables,
3760 &resolved.cross_context.consumed_contexts,
3761 ),
3762 target_test_actors(unit_tables.get(target_name)),
3763 prop.forall.where_pred.as_ref(),
3764 checker::CheckSinks {
3765 tys,
3766 expr_types: &mut expr_types,
3767 errors,
3768 refs,
3769 hints: &mut no_hints,
3770 locals: &mut no_locals,
3771 requirements: &mut no_requirements,
3772 callees: &mut callees,
3773 },
3774 );
3775
3776 if let [(var, Some(ty))] = binding_types.as_slice()
3779 && let Some(refinement) = named_refinement(*ty, &resolved.types, tys)
3780 && let [stmt] = prop.forall.body.statements.as_slice()
3781 && let Statement::Expect(e) = stmt
3782 && predicate_restates_refinement(&e.value, var, refinement)
3783 {
3784 errors.push(
3785 CompileError::new(
3786 "bynk.property.restates_refinement",
3787 prop.forall.body.span,
3788 format!(
3789 "property `{}` merely re-checks a refinement type `{}` already guarantees",
3790 prop.name,
3791 ty.display(tys)
3792 ),
3793 )
3794 .with_note(
3795 "a property earns its keep by asserting behaviour over valid inputs, not by restating the type's refinement",
3796 ),
3797 );
3798 }
3799
3800 if let Some((run_var, agent)) = &history_binding
3805 && history_restates_invariant(prop, run_var, agent)
3806 {
3807 errors.push(
3808 CompileError::new(
3809 "bynk.history.restates_invariant",
3810 prop.forall.body.span,
3811 format!(
3812 "history property `{}` merely re-checks a guarantee agent `{}`'s `invariant`/`transition` already enforces on every reached state",
3813 prop.name, agent.name.name
3814 ),
3815 )
3816 .with_note(
3817 "a history property earns its keep by asserting a cross-step protocol, not by restating a per-state invariant",
3818 ),
3819 );
3820 }
3821}
3822
3823fn record_type_refs_in_property(
3826 type_ref: &TypeRef,
3827 resolved: &ResolvedCommons,
3828 refs: &mut RefSink,
3829) {
3830 checker::record_type_refs(type_ref, &resolved.types, &HashSet::new(), refs);
3831}
3832
3833#[derive(Clone, Default)]
3839pub struct SuiteView {
3840 pub types: HashMap<String, Arc<TypeDecl>>,
3841 pub fns: HashMap<String, Arc<FnDecl>>,
3842 pub methods: HashMap<String, ResolverMethodTable>,
3843 pub hidden: BTreeMap<String, String>,
3844 pub conflicts: Vec<crate::project_model::ViewConflict>,
3847 pub assets: HashMap<String, bynk_syntax::ast::AssetDecl>,
3853 pub embeds: HashMap<String, bynk_syntax::ast::EmbedDecl>,
3858}
3859
3860impl SuiteView {
3861 fn merge_embeds(
3864 &mut self,
3865 unit: &str,
3866 unit_tables: &HashMap<String, UnitTable>,
3867 unit_uses: &HashMap<String, Vec<String>>,
3868 ) {
3869 let own = unit_tables.get(unit).into_iter();
3870 let used = unit_uses
3871 .get(unit)
3872 .into_iter()
3873 .flatten()
3874 .filter_map(|u| unit_tables.get(u));
3875 for table in own.chain(used) {
3876 for (name, decl) in &table.embeds {
3877 self.embeds
3878 .entry(name.clone())
3879 .or_insert_with(|| decl.clone());
3880 }
3881 }
3882 }
3883
3884 fn merge_assets(
3887 &mut self,
3888 unit: &str,
3889 unit_tables: &HashMap<String, UnitTable>,
3890 unit_uses: &HashMap<String, Vec<String>>,
3891 ) {
3892 self.merge_assets_where(unit, unit_tables, unit_uses, |_| true);
3893 }
3894
3895 fn merge_assets_where(
3897 &mut self,
3898 unit: &str,
3899 unit_tables: &HashMap<String, UnitTable>,
3900 unit_uses: &HashMap<String, Vec<String>>,
3901 keep: impl Fn(&bynk_syntax::ast::AssetDecl) -> bool,
3902 ) {
3903 let own = unit_tables.get(unit).into_iter();
3904 let used = unit_uses
3905 .get(unit)
3906 .into_iter()
3907 .flatten()
3908 .filter_map(|u| unit_tables.get(u));
3909 for table in own.chain(used) {
3910 for (name, decl) in table.assets.iter().filter(|(_, d)| keep(d)) {
3911 self.assets
3912 .entry(name.clone())
3913 .or_insert_with(|| decl.clone());
3914 }
3915 }
3916 }
3917
3918 pub fn reach(&self) -> crate::project_model::ViewReach {
3920 crate::project_model::ViewReach {
3921 hidden: self.hidden.clone(),
3922 conflicts: self.conflicts.clone(),
3923 }
3924 }
3925
3926 fn merge(&mut self, t: &UnitTable, with_fns: bool, with_statics: bool) {
3930 for (n, d) in &t.types {
3931 self.types.entry(n.clone()).or_insert_with(|| d.clone());
3932 }
3933 if with_fns {
3934 for (n, f) in &t.fns {
3935 self.fns.entry(n.clone()).or_insert_with(|| f.clone());
3936 }
3937 }
3938 for (n, mt) in &t.methods {
3939 let entry = self.methods.entry(n.clone()).or_default();
3940 for (m, decl) in &mt.instance {
3941 entry
3942 .instance
3943 .entry(m.clone())
3944 .or_insert_with(|| decl.clone());
3945 }
3946 if with_statics {
3947 for (m, decl) in &mt.statics {
3948 entry
3949 .statics
3950 .entry(m.clone())
3951 .or_insert_with(|| decl.clone());
3952 }
3953 }
3954 }
3955 }
3956
3957 fn close(
3960 &mut self,
3961 imported: &[String],
3962 unit_tables: &HashMap<String, UnitTable>,
3963 unit_uses: &HashMap<String, Vec<String>>,
3964 ) {
3965 let reach = crate::project_model::close_view_types(
3966 imported,
3967 unit_tables,
3968 unit_uses,
3969 &mut self.types,
3970 &self.fns,
3971 &mut self.methods,
3972 );
3973 self.hidden = reach.hidden;
3974 self.conflicts = reach.conflicts;
3975 }
3976}
3977
3978pub fn privileged_view(
3984 owning_unit: &str,
3985 unit_tables: &HashMap<String, UnitTable>,
3986 unit_uses: &HashMap<String, Vec<String>>,
3987 unit_consumes: &HashMap<String, Vec<String>>,
3988) -> Option<SuiteView> {
3989 let local = unit_tables.get(owning_unit)?;
3990 let mut view = SuiteView {
3991 types: local.types.clone(),
3992 fns: local.fns.clone(),
3993 methods: local.methods.clone(),
3994 ..SuiteView::default()
3995 };
3996 let mut imported: Vec<String> = Vec::new();
3997 for t in unit_uses.get(owning_unit).into_iter().flatten() {
3998 if let Some(used) = unit_tables.get(t) {
3999 view.merge(used, true, true);
4000 imported.push(t.clone());
4001 }
4002 }
4003 for t in unit_consumes.get(owning_unit).into_iter().flatten() {
4008 if let Some(used) = unit_tables.get(t) {
4009 view.merge(used, false, false);
4010 imported.push(t.clone());
4011 }
4012 }
4013 view.merge_assets(owning_unit, unit_tables, unit_uses);
4014 view.merge_embeds(owning_unit, unit_tables, unit_uses);
4015 for consumed in unit_consumes.get(owning_unit).into_iter().flatten() {
4020 view.merge_assets_where(consumed, unit_tables, unit_uses, |d| d.at.is_none());
4021 }
4022 let hidden_by_local = |n: &String| crate::symbols::local_value_kind(local, n).is_some();
4026 view.fns
4027 .retain(|n, _| local.fns.contains_key(n) || !hidden_by_local(n));
4028 view.assets
4029 .retain(|n, _| local.assets.contains_key(n) || !hidden_by_local(n));
4030 view.embeds
4031 .retain(|n, _| local.embeds.contains_key(n) || !hidden_by_local(n));
4032 view.close(&imported, unit_tables, unit_uses);
4033 Some(view)
4034}
4035
4036pub fn integration_view(
4041 uses_targets: &[String],
4042 participants: &[String],
4043 unit_tables: &HashMap<String, UnitTable>,
4044 unit_uses: &HashMap<String, Vec<String>>,
4045) -> SuiteView {
4046 let mut view = SuiteView::default();
4047 let mut imported: Vec<String> = Vec::new();
4048 for (units, with_fns) in [(uses_targets, true), (participants, false)] {
4049 for u in units {
4050 if let Some(t) = unit_tables.get(u) {
4051 view.merge(t, with_fns, true);
4052 imported.push(u.clone());
4053 }
4054 }
4055 }
4056 if let Some((target, others)) = participants.split_first() {
4064 view.merge_assets(target, unit_tables, unit_uses);
4065 for other in others {
4066 view.merge_assets_where(other, unit_tables, unit_uses, |d| d.at.is_none());
4067 }
4068 if let Some(local) = unit_tables.get(target) {
4072 view.assets.retain(|n, _| {
4073 local.assets.contains_key(n) || crate::symbols::local_value_kind(local, n).is_none()
4074 });
4075 }
4076 }
4077 view.close(&imported, unit_tables, unit_uses);
4078 view
4079}
4080
4081pub fn build_privileged_resolved(
4086 owning_unit: &str,
4087 unit_tables: &HashMap<String, UnitTable>,
4088 unit_uses: &HashMap<String, Vec<String>>,
4089 unit_consumes: &HashMap<String, Vec<String>>,
4090 unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
4091) -> Option<(ResolvedCommons, crate::project_model::ViewReach)> {
4092 let local = unit_tables.get(owning_unit)?;
4093 let view = privileged_view(owning_unit, unit_tables, unit_uses, unit_consumes)?;
4094 let reach = view.reach();
4095 let assets = view.assets.clone();
4096 let embeds = view.embeds.clone();
4097 let SuiteView {
4098 types,
4099 fns,
4100 methods,
4101 ..
4102 } = view;
4103 let cross_context = build_cross_context_info(
4104 owning_unit,
4105 unit_consumes,
4106 unit_consumes_aliases,
4107 unit_uses,
4108 unit_tables,
4109 );
4110 let synthetic_commons = Commons {
4111 name: QualifiedName {
4112 parts: owning_unit
4113 .split('.')
4114 .map(|part| Ident {
4115 name: part.to_string(),
4116 span: Span::default(),
4117 })
4118 .collect(),
4119 span: Span::default(),
4120 },
4121 items: Vec::new(),
4122 uses: Vec::new(),
4123 documentation: None,
4124 form: CommonsForm::Brace,
4125 span: Span::default(),
4126 trivia: Trivia::default(),
4127 trailing_comments: Vec::new(),
4128 };
4129 let agents_for_resolved = unit_tables
4130 .get(owning_unit)
4131 .map(|t| t.agents.clone())
4132 .unwrap_or_default();
4133 let no_local_events = HashMap::new();
4134 let resolved = ResolvedCommons::new(
4135 synthetic_commons,
4136 types,
4137 &local.types,
4138 fns,
4139 methods,
4140 agents_for_resolved,
4141 &no_local_events,
4145 cross_context,
4146 HashMap::new(),
4147 false,
4148 HashSet::new(),
4149 )
4150 .with_assets(assets)
4152 .with_embeds(embeds)
4153 .with_shadowed_imports(crate::project_model::shadowed_imports_in(
4156 local,
4157 unit_uses
4158 .get(owning_unit)
4159 .into_iter()
4160 .flatten()
4161 .filter_map(|t| unit_tables.get(t).map(|u| (t, u))),
4162 ));
4163 Some((resolved, reach))
4164}
4165
4166#[cfg(test)]
4167mod tests {
4168 use super::*;
4169
4170 fn handler_body(src: &str) -> Block {
4172 let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
4173 let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
4174 let SourceUnit::Context(ctx) = unit else {
4175 panic!("expected a context unit");
4176 };
4177 ctx.items
4178 .into_iter()
4179 .find_map(|item| match item {
4180 bynk_syntax::ast::CommonsItem::Service(s) => s.handlers.into_iter().next(),
4181 _ => None,
4182 })
4183 .expect("a service handler")
4184 .body
4185 }
4186
4187 const SRC: &str = "context demo\n\
4188 service svc {\n\
4189 on call(n: Int) -> Effect[Int] {\n\
4190 let a <- first(n)\n\
4191 let b = if n > 0 { let c = second(n)\n c } else { third(n) }\n\
4192 fourth(a + b)\n\
4193 }\n\
4194 }\n";
4195
4196 #[test]
4200 fn exprs_in_order_is_source_order() {
4201 let body = handler_body(SRC);
4202 let calls: Vec<&str> = exprs_in_order(&body)
4203 .into_iter()
4204 .filter_map(|e| match &e.kind {
4205 ExprKind::Call { name, .. } => Some(name.name.as_str()),
4206 _ => None,
4207 })
4208 .collect();
4209 assert_eq!(calls, ["first", "second", "third", "fourth"]);
4210 }
4211
4212 #[test]
4215 fn blocks_deep_reaches_nested_lets() {
4216 let body = handler_body(SRC);
4217 let lets: Vec<String> = blocks_deep(&body)
4218 .iter()
4219 .flat_map(|b| &b.statements)
4220 .filter_map(|s| match s {
4221 Statement::Let(l) | Statement::EffectLet(l) => Some(l.name.name.clone()),
4222 _ => None,
4223 })
4224 .collect();
4225 assert!(lets.contains(&"c".to_string()), "{lets:?}");
4226 assert!(lets.contains(&"a".to_string()) && lets.contains(&"b".to_string()));
4227 }
4228
4229 const CART_API: &str = "context shop.api
4231
4232type UserId = String where NonEmpty
4233actor User { auth = Bearer(secret = \"AUTH_SECRET\"), identity = UserId }
4234
4235type Item = { sku: String }
4236
4237service api from http {
4238 on POST(\"/cart\") (body: Item) -> Effect[HttpResult[Item]] by u: User {
4239 Created(body)
4240 }
4241}
4242";
4243
4244 fn nobody_case(body: &str) -> crate::testkit::Analysed {
4245 crate::testkit::analyse(&[
4246 ("shop/api.bynk", CART_API),
4247 (
4248 "shop/tests/api.test.bynk",
4249 &format!(
4250 "suite shop.api as system {{\n case \"c\" as unit {{\n{body}\n }}\n}}\n"
4251 ),
4252 ),
4253 ])
4254 }
4255
4256 #[test]
4257 fn a_unit_case_calling_by_nobody_needs_system() {
4258 nobody_case(
4259 " let r <- api.POST(\"/cart\", Item { sku: \"w\" }) by Nobody\n expect r is Rejected(Unauthorized)",
4260 )
4261 .assert_reports("bynk.test.credential_needs_system");
4262 }
4263
4264 const HOOKS: &str = "context pay.hooks
4266
4267actor Processor { auth = Signature(secret = \"HOOK\", header = \"X-Sig\", timestamp = \"X-Ts\", tolerance = 300) }
4268
4269service webhooks from http {
4270 on POST(\"/hook\") (body: String) -> Effect[HttpResult[String]] by Processor {
4271 Ok(body)
4272 }
4273}
4274
4275service jobs {
4276 on call(n: Int) -> Effect[Int] { Effect.pure(n) }
4277}
4278";
4279
4280 fn hooks_case(tier: &str, body: &str) -> crate::testkit::Analysed {
4281 crate::testkit::analyse(&[
4282 ("pay/hooks.bynk", HOOKS),
4283 (
4284 "pay/tests/hooks.test.bynk",
4285 &format!("suite pay.hooks{tier} {{\n case \"c\" {{\n{body}\n }}\n}}\n"),
4286 ),
4287 ])
4288 }
4289
4290 const SUMS: &str = "context pay.sums
4293
4294type UserId = String where NonEmpty
4295
4296actor User { auth = Bearer(secret = \"AUTH\"), identity = UserId }
4297
4298actor Untimed { auth = Signature(secret = \"HOOK\", header = \"X-Sig\", timestamp = \"X-Ts\") }
4299
4300actor Timed { auth = Signature(secret = \"HOOK\", header = \"X-Sig\", timestamp = \"X-Ts\", tolerance = 300) }
4301
4302service api from http {
4303 on POST(\"/untimed\") (body: String) -> Effect[HttpResult[String]] by Untimed {
4304 Ok(body)
4305 }
4306
4307 on POST(\"/mixed\") (body: String) -> Effect[HttpResult[String]] by who: User | Timed {
4308 match who {
4309 User(u) => Ok(u)
4310 Timed => Ok(body)
4311 }
4312 }
4313
4314 on POST(\"/open\") (body: String) -> Effect[HttpResult[String]] by who: Timed | Visitor {
4315 match who {
4316 Timed => Ok(body)
4317 Visitor => Ok(\"stranger\")
4318 }
4319 }
4320
4321 on GET(\"/greet\") () -> Effect[HttpResult[String]] by who: User | Visitor {
4322 match who {
4323 User(u) => Ok(u)
4324 Visitor => Ok(\"stranger\")
4325 }
4326 }
4327}
4328";
4329
4330 fn sums_case(body: &str) -> crate::testkit::Analysed {
4331 crate::testkit::analyse(&[
4332 ("pay/sums.bynk", SUMS),
4333 (
4334 "pay/tests/sums.test.bynk",
4335 &format!("suite pay.sums as system {{\n case \"c\" {{\n{body}\n }}\n}}\n"),
4336 ),
4337 ])
4338 }
4339
4340 #[test]
4341 fn a_stale_call_needs_a_tolerance_not_just_a_timestamp() {
4342 sums_case(
4343 " let r <- api.POST(\"/untimed\", \"x\") by Stale\n expect r is Rejected(Unauthorized)",
4344 )
4345 .assert_reports("bynk.test.stale_needs_timestamp");
4346 }
4347
4348 #[test]
4349 fn forged_stale_and_nobody_are_admitted_on_a_sum_with_a_signature_member() {
4350 sums_case(
4351 " let a <- api.POST(\"/mixed\", \"x\") by Forged\n let b <- api.POST(\"/mixed\", \"x\") by Stale\n let c <- api.POST(\"/mixed\", \"x\") by Nobody\n expect a is Rejected(Unauthorized)\n expect b is Rejected(Unauthorized)\n expect c is Rejected(Unauthorized)",
4352 )
4353 .assert_clean();
4354 }
4355
4356 #[test]
4359 fn forged_and_stale_are_refused_on_a_sum_with_visitor() {
4360 for who in ["Forged", "Stale"] {
4361 sums_case(&format!(
4362 " let r <- api.POST(\"/open\", \"x\") by {who}\n expect r is Rejected(Unauthorized)"
4363 ))
4364 .assert_reports("bynk.test.forged_needs_signature_route");
4365 }
4366 }
4367
4368 #[test]
4369 fn nobody_is_refused_on_a_sum_with_visitor() {
4370 sums_case(
4371 " let r <- api.GET(\"/greet\") by Nobody\n expect r is Rejected(Unauthorized)",
4372 )
4373 .assert_reports("bynk.test.nobody_needs_secured_route");
4374 sums_case(
4375 " let r <- api.POST(\"/open\", \"x\") by Nobody\n expect r is Rejected(Unauthorized)",
4376 )
4377 .assert_reports("bynk.test.nobody_needs_secured_route");
4378 }
4379
4380 #[test]
4381 fn a_forged_call_needs_a_signature_member_on_a_sum() {
4382 sums_case(
4383 " let r <- api.GET(\"/greet\") by Forged\n expect r is Rejected(Unauthorized)",
4384 )
4385 .assert_reports("bynk.test.forged_needs_signature_route");
4386 }
4387
4388 #[test]
4389 fn a_forged_fault_claim_below_system_is_refused() {
4390 hooks_case(
4391 "",
4392 " expect webhooks.POST(\"/hook\", \"x\") by Forged faults",
4393 )
4394 .assert_reports("bynk.test.faults_needs_in_process");
4395 }
4396
4397 #[test]
4398 fn a_stale_call_at_unit_needs_system() {
4399 hooks_case(
4400 "",
4401 " let r <- webhooks.POST(\"/hook\", \"x\") by Stale\n expect r is Rejected(Unauthorized)",
4402 )
4403 .assert_reports("bynk.test.forged_needs_system");
4404 }
4405
4406 #[test]
4407 fn a_forged_call_service_needs_a_signature_route() {
4408 hooks_case("", " let r <- jobs.call(1) by Forged\n expect r == 1")
4409 .assert_reports("bynk.test.forged_needs_signature_route");
4410 }
4411
4412 #[test]
4413 fn a_by_nobody_nested_in_a_block_needs_system_too() {
4414 nobody_case(
4416 " let ok = if true {\n let r <- api.POST(\"/cart\", Item { sku: \"w\" }) by Nobody\n r is Rejected(Unauthorized)\n } else { false }\n expect ok",
4417 )
4418 .assert_reports("bynk.test.credential_needs_system");
4419 }
4420}
4421
4422#[cfg(test)]
4426mod typed_outcome_tests {
4427 const API: &str = "context app.web
4428
4429type Item = { sku: String }
4430
4431service api from http {
4432 on GET(\"/x\") () -> Effect[HttpResult[()]] by Visitor {
4433 Raw(Bytes.fromUtf8(\"hi\"), \"text/plain\")
4434 }
4435 on GET(\"/name\") () -> Effect[HttpResult[String]] by Visitor {
4436 Ok(\"ada\")
4437 }
4438 on GET(\"/item\") () -> Effect[HttpResult[Item]] by Visitor {
4439 Ok(Item { sku: \"w\" })
4440 }
4441}
4442
4443service calc {
4444 on call(n: Int) -> Effect[Int] {
4445 n
4446 }
4447}
4448
4449service sweeper from cron {
4450 on schedule(\"0 0 * * *\") () -> Effect[Result[(), String]] {
4451 Ok(())
4452 }
4453}
4454
4455service intake from queue(\"orders\") {
4456 on message(item: Item) -> Effect[QueueResult] {
4457 Ack
4458 }
4459}
4460";
4461
4462 fn case(body: &str) -> crate::testkit::Analysed {
4463 crate::testkit::analyse(&[
4464 ("app/web.bynk", API),
4465 (
4466 "app/tests/web.test.bynk",
4467 &format!("suite app.web {{\n case \"c\" {{\n{body}\n }}\n}}\n"),
4468 ),
4469 ])
4470 }
4471
4472 #[test]
4475 fn an_http_outcome_has_the_handlers_result_type() {
4476 let get = " let r <- api.GET(\"/name\") by Visitor\n";
4477 case(&format!("{get} match r {{\n Ok(v) => expect v.length() == 3\n _ => expect false\n }}"))
4478 .assert_clean();
4479 case(&format!("{get} match r {{\n Ok(v) => expect v.nosuch() == 3\n _ => expect false\n }}"))
4480 .assert_reports("bynk.types.method_not_found");
4481 case(&format!(
4482 "{get} expect r is Ok(v) && v.startsWith(\"a\")"
4483 ))
4484 .assert_clean();
4485 case(&format!("{get} expect r == Ok(\"ada\")")).assert_clean();
4486 let item = " let r <- api.GET(\"/item\") by Visitor\n";
4487 case(&format!(
4488 "{item} expect r is Ok(i) && i.sku.length() == 1"
4489 ))
4490 .assert_clean();
4491 case(&format!("{item} expect r is Ok(i) && i.nosuch == 1"))
4492 .assert_reports("bynk.types.unknown_field");
4493 }
4494
4495 #[test]
4498 fn fixed_payloads_and_arms_are_checked() {
4499 let get = " let r <- api.GET(\"/x\") by Visitor\n";
4500 case(&format!("{get} match r {{\n Raw(body, ct) => expect body.length() == 2 && ct.startsWith(\"text/\")\n _ => expect false\n }}"))
4501 .assert_clean();
4502 case(&format!("{get} match r {{\n Raw(_, _) => expect nope == 1\n _ => expect false\n }}"))
4503 .assert_reports("bynk.resolve.unknown_name");
4504 case(&format!("{get} match r {{\n BadRequest(m) | NotFound => expect m.length() == 9\n _ => expect false\n }}"))
4505 .assert_reports("bynk.types.or_pattern_binding_mismatch");
4506 case(&format!("{get} expect r is Bogus(_)"))
4507 .assert_reports("bynk.types.is_unknown_variant");
4508 }
4509
4510 #[test]
4512 fn a_call_outcome_has_the_handlers_return_type() {
4513 case(" let n <- calc.call(2)\n expect n.abs() == 2").assert_clean();
4514 case(" let n <- calc.call(2)\n expect n.length() == 2")
4515 .assert_reports("bynk.types.method_not_found");
4516 }
4517
4518 #[test]
4521 fn cron_and_queue_outcomes_are_typed() {
4522 case(" let r <- sweeper.schedule(\"0 0 * * *\")\n match r {\n Ok(_) => expect true\n Err(e) => expect e.length() > 0\n }")
4523 .assert_clean();
4524 case(" let r <- intake.message(Item { sku: \"x\" })\n expect r is Ack")
4525 .assert_clean();
4526 case(" let r <- intake.message(Item { sku: \"x\" })\n match r {\n Retry(why) => expect why.length() > 0\n Ack => expect true\n }")
4527 .assert_clean();
4528 case(" let r <- intake.message(Item { sku: \"x\" })\n expect r is Ok(_)")
4529 .assert_reports("bynk.types.is_unknown_variant");
4530 }
4531
4532 const CART_API: &str = "context shop.api
4534
4535type UserId = String where NonEmpty
4536actor User { auth = Bearer(secret = \"AUTH_SECRET\"), identity = UserId }
4537
4538type Item = { sku: String }
4539
4540service api from http {
4541 on POST(\"/cart\") (body: Item) -> Effect[HttpResult[Item]] by u: User {
4542 Created(body)
4543 }
4544}
4545";
4546
4547 fn system_case(body: &str) -> crate::testkit::Analysed {
4548 crate::testkit::analyse(&[
4549 ("shop/api.bynk", CART_API),
4550 (
4551 "shop/tests/api.test.bynk",
4552 &format!("suite shop.api as system {{\n case \"c\" {{\n{body}\n }}\n}}\n"),
4553 ),
4554 ])
4555 }
4556
4557 #[test]
4559 fn a_typed_system_call_is_the_handlers_result() {
4560 system_case(" let r <- api.POST(\"/cart\", Item { sku: \"w\" }) by User(\"a\")\n expect r is Created(i) && i.sku == \"w\"")
4561 .assert_clean();
4562 system_case(" let r <- api.POST(\"/cart\", Item { sku: \"w\" }) by User(\"a\")\n expect r is Rejected(_)")
4563 .assert_reports("bynk.types.is_unknown_variant");
4564 }
4565
4566 #[test]
4570 fn a_call_past_the_handlers_types_is_an_http_outcome() {
4571 let wire = " let r <- api.POST(\"/cart\", Wire(\"{}\")) by User(\"a\")\n";
4572 system_case(&format!("{wire} match r {{\n Rejected(RefinementViolation(d)) => expect d.length() > 0\n Rejected(_) => expect false\n Handled(Created(i)) => expect i.sku == \"w\"\n Handled(_) => expect false\n }}"))
4573 .assert_clean();
4574 system_case(&format!("{wire} match r {{\n Rejected(Unauthorized(d)) => expect true\n _ => expect false\n }}"))
4575 .assert_reports("bynk.types.pattern_arity");
4576 system_case(&format!("{wire} expect r is Rejected(Refinement(_))"))
4577 .assert_reports("bynk.types.unknown_variant_in_pattern");
4578 system_case(&format!("{wire} expect r is Created(_)"))
4579 .assert_reports("bynk.types.is_unknown_variant");
4580 system_case(" let r <- api.POST(\"/cart\", Item { sku: \"w\" }) by Nobody\n expect r is Rejected(Unauthorized)")
4581 .assert_clean();
4582 system_case(" let r <- api.POST(\"/cart\", Wire(\"{}\")) by Nobody\n expect r is Rejected(Unauthorized)")
4584 .assert_clean();
4585 system_case(
4586 " let r <- api.POST(\"/cart\", Wire(\"{}\")) by Nobody\n expect r is Created(_)",
4587 )
4588 .assert_reports("bynk.types.is_unknown_variant");
4589 system_case(
4590 " let r <- api.DELETE(\"/cart\")\n expect r is Rejected(MethodNotAllowed)",
4591 )
4592 .assert_clean();
4593 system_case(" let r <- api.DELETE(\"/cart\")\n expect r is NotFound")
4594 .assert_reports("bynk.types.is_unknown_variant");
4595 }
4596}
4597
4598#[cfg(test)]
4599mod participant_tests {
4600 use super::*;
4601
4602 type Subscribers = BTreeMap<(String, String), Vec<(String, String)>>;
4605
4606 fn project() -> (
4612 HashMap<String, Vec<String>>,
4613 BTreeMap<String, UnitKind>,
4614 Subscribers,
4615 ) {
4616 let edges = [
4617 ("ops.hub", &["ops.store", "bynk"][..]),
4618 ("ops.audit", &["ops.hub", "ops.archive"][..]),
4619 ("ops.mirror", &["ops.store"][..]),
4620 ("ops.digest", &["ops.audit"][..]),
4621 ];
4622 let unit_consumes = edges
4623 .iter()
4624 .map(|(u, ds)| (u.to_string(), ds.iter().map(|d| d.to_string()).collect()))
4625 .collect();
4626 let mut kinds: BTreeMap<String, UnitKind> = [
4627 "ops.hub",
4628 "ops.store",
4629 "ops.audit",
4630 "ops.archive",
4631 "ops.mirror",
4632 "ops.digest",
4633 ]
4634 .iter()
4635 .map(|c| (c.to_string(), UnitKind::Context))
4636 .collect();
4637 kinds.insert("bynk".to_string(), UnitKind::Adapter);
4638 let sub = |owner: &str, event: &str, ctx: &str| {
4639 (
4640 (owner.to_string(), event.to_string()),
4641 vec![(ctx.to_string(), "On".to_string())],
4642 )
4643 };
4644 let subscribers = [
4645 sub("ops.hub", "Ping", "ops.audit"),
4646 sub("ops.store", "Saved", "ops.mirror"),
4647 sub("ops.audit", "Noted", "ops.digest"),
4648 ]
4649 .into_iter()
4650 .collect();
4651 (unit_consumes, kinds, subscribers)
4652 }
4653
4654 #[test]
4657 fn participants_are_the_consumes_closure() {
4658 let (consumes, kinds, _) = project();
4659 assert_eq!(
4660 infer_participants("ops.hub", &consumes, &kinds),
4661 ["ops.hub", "ops.store"]
4662 );
4663 }
4664
4665 #[test]
4669 fn the_harness_adds_subscribers_and_their_closures_transitively() {
4670 let (consumes, kinds, subscribers) = project();
4671 assert_eq!(
4672 infer_harness_participants("ops.hub", &consumes, &kinds, &subscribers),
4673 [
4674 "ops.hub",
4675 "ops.store",
4676 "ops.audit",
4677 "ops.mirror",
4678 "ops.archive",
4679 "ops.digest"
4680 ]
4681 );
4682 }
4683
4684 #[test]
4686 fn without_subscribers_the_harness_is_the_case_set() {
4687 let (consumes, kinds, _) = project();
4688 assert_eq!(
4689 infer_harness_participants("ops.hub", &consumes, &kinds, &BTreeMap::new()),
4690 infer_participants("ops.hub", &consumes, &kinds)
4691 );
4692 }
4693}
4694
4695#[cfg(test)]
4699mod history_pattern_tests {
4700 use super::*;
4701
4702 fn ident(name: &str) -> Ident {
4703 Ident {
4704 name: name.to_string(),
4705 span: Span::default(),
4706 }
4707 }
4708
4709 fn variant(type_name: Option<&str>, name: &str) -> Pattern {
4710 Pattern::Variant {
4711 type_name: type_name.map(ident),
4712 variant: ident(name),
4713 bindings: Vec::new(),
4714 span: Span::default(),
4715 }
4716 }
4717
4718 fn payload_variant(name: &str) -> Pattern {
4719 Pattern::Variant {
4720 type_name: None,
4721 variant: ident(name),
4722 bindings: vec![PatternBinding {
4723 kind: PatternBindingKind::Positional {
4724 pattern: Pattern::Wildcard(Span::default()),
4725 },
4726 span: Span::default(),
4727 }],
4728 span: Span::default(),
4729 }
4730 }
4731
4732 fn int(n: i64) -> Pattern {
4733 Pattern::Literal {
4734 value: LiteralValue::Int(n),
4735 span: Span::default(),
4736 }
4737 }
4738
4739 #[test]
4740 fn same_payload_free_variant_matches() {
4741 assert!(history_pattern_matches(
4742 &variant(None, "Closed"),
4743 &variant(None, "Closed")
4744 ));
4745 assert!(history_pattern_matches(
4746 &variant(Some("Status"), "Closed"),
4747 &variant(Some("Status"), "Closed")
4748 ));
4749 }
4750
4751 #[test]
4752 fn different_variant_names_do_not_match() {
4753 assert!(!history_pattern_matches(
4754 &variant(None, "Open"),
4755 &variant(None, "Closed")
4756 ));
4757 }
4758
4759 #[test]
4760 fn qualifier_present_vs_absent_does_not_match() {
4761 assert!(!history_pattern_matches(
4762 &variant(Some("Status"), "Closed"),
4763 &variant(None, "Closed")
4764 ));
4765 assert!(!history_pattern_matches(
4766 &variant(None, "Closed"),
4767 &variant(Some("Status"), "Closed")
4768 ));
4769 assert!(!history_pattern_matches(
4770 &variant(Some("Status"), "Closed"),
4771 &variant(Some("Other"), "Closed")
4772 ));
4773 }
4774
4775 #[test]
4776 fn literals_match_by_value() {
4777 assert!(history_pattern_matches(&int(3), &int(3)));
4778 assert!(!history_pattern_matches(&int(3), &int(4)));
4779 assert!(!history_pattern_matches(&int(3), &variant(None, "Closed")));
4780 }
4781
4782 #[test]
4783 fn payload_variant_never_matches() {
4784 assert!(!history_pattern_matches(
4785 &payload_variant("Some"),
4786 &payload_variant("Some")
4787 ));
4788 assert!(!history_pattern_matches(
4789 &payload_variant("Some"),
4790 &variant(None, "Some")
4791 ));
4792 }
4793}
4794
4795#[cfg(test)]
4798mod event_address_tests {
4799 use crate::testkit::analyse;
4800
4801 const ORDER: &str =
4802 "context shop.order\n\nexports transparent { Placed }\n\nevent Placed = { id: String }\n";
4803
4804 fn ledger(consumes: &str, extra: &str) -> String {
4805 format!(
4806 "context shop.ledger\n\n{consumes}\n{extra}\nservice OnPlaced from Events(Placed) {{\n on event(e: Placed) -> Effect[()] {{\n ()\n }}\n}}\n"
4807 )
4808 }
4809
4810 fn suite(body: &str) -> String {
4811 format!("suite shop.ledger {{\n case \"c\" {{\n{body}\n }}\n}}\n")
4812 }
4813
4814 #[test]
4815 fn an_envelope_the_suite_cannot_name_notes_consumes_bynk() {
4816 let a = analyse(&[
4817 ("shop/order.bynk", ORDER),
4818 ("shop/ledger.bynk", &ledger("consumes shop.order", "")),
4819 (
4820 "shop/tests/ledger.test.bynk",
4821 &suite(" let _ <- OnPlaced.event(Placed { id: \"o1\" }, \"evt-1\")"),
4822 ),
4823 ]);
4824 let e = a
4825 .analysis
4826 .errors
4827 .iter()
4828 .find(|e| e.error.category == "bynk.types.argument_mismatch")
4829 .expect("an argument mismatch on the envelope");
4830 assert!(
4831 e.error
4832 .notes
4833 .iter()
4834 .any(|n| n.contains("must `consumes bynk`")),
4835 "expected the `consumes bynk` note, got {:?}",
4836 e.error.notes
4837 );
4838 }
4839
4840 #[test]
4841 fn a_same_named_event_outside_the_targets_scope_is_a_plain_mismatch() {
4842 let a = analyse(&[
4845 ("shop/order.bynk", ORDER),
4846 (
4847 "shop/archive.bynk",
4848 "context shop.archive\n\nevent Note = { text: String }\n",
4849 ),
4850 (
4851 "shop/ledger.bynk",
4852 &ledger("consumes shop.order", "\ntype Note = { text: String }\n"),
4853 ),
4854 (
4855 "shop/tests/ledger.test.bynk",
4856 &suite(" let _ <- OnPlaced.event(Note { text: \"n\" })"),
4857 ),
4858 ]);
4859 a.assert_reports("bynk.types.argument_mismatch");
4860 assert!(
4861 !a.categories().contains(&"bynk.test.event_not_subscribed"),
4862 "a record named like an unconsumed context's event is not an event: {:?}",
4863 a.categories()
4864 );
4865 }
4866
4867 #[test]
4868 fn a_same_named_event_in_an_unconsumed_context_leaves_the_publisher_unique() {
4869 let a = analyse(&[
4870 ("shop/order.bynk", ORDER),
4871 (
4872 "shop/archive.bynk",
4873 "context shop.archive\n\nexports transparent { Placed }\n\nevent Placed = { id: String }\n",
4874 ),
4875 ("shop/ledger.bynk", &ledger("consumes shop.order", "")),
4876 (
4877 "shop/tests/ledger.test.bynk",
4878 &suite(" let _ <- OnPlaced.event(Placed { id: \"o1\" })"),
4879 ),
4880 ]);
4881 a.assert_clean();
4882 }
4883
4884 #[test]
4885 fn two_consumed_publishers_refuse_a_default_envelope() {
4886 let a = analyse(&[
4887 ("shop/order.bynk", ORDER),
4888 (
4889 "shop/archive.bynk",
4890 "context shop.archive\n\nexports transparent { Placed }\n\nevent Placed = { id: String }\n",
4891 ),
4892 (
4893 "shop/ledger.bynk",
4894 &ledger("consumes shop.order\nconsumes shop.archive", ""),
4895 ),
4896 (
4897 "shop/tests/ledger.test.bynk",
4898 &suite(" let _ <- OnPlaced.event(Placed { id: \"o1\" })"),
4899 ),
4900 ]);
4901 a.assert_reports("bynk.test.event_publisher_ambiguous");
4902 }
4903}