Skip to main content

bynk_check/
test_suites.rs

1//! Test/integration-suite checking (P5.4,
2//! `design/tracks/semantics-in-the-checker.md` §6) — closes category 7 of
3//! `bynk-check/src/analysis.rs`'s own residual-gap accounting, the last of
4//! the seven. `bynk-emit/src/project/tests_emit.rs` held
5//! `process_tests`/`process_integration_tests`, real production code (not
6//! fixture noise, despite the filename) checking + emitting `suite`/`test
7//! integration` bodies — but it ran only inside `bynk-emit::run_checks`
8//! (`Mode::Analyse` included), never inside `bynk_check::analysis::analyse_project`,
9//! the entry point the LSP now uses. That gap meant no diagnostics *and* no
10//! `RefSink` bindings (go-to-definition/find-references) for anything inside
11//! a test file, in the editor — see `analysis.rs`'s own module doc for the
12//! full accounting this closes.
13//!
14//! **What moved here** — every check-only helper `process_tests`/
15//! `process_integration_tests` used, plus every function that is genuinely
16//! **dual-use**: called both by this module's own [`phase_test_bodies`]/
17//! [`phase_integration_bodies`] (the checking half, real diagnostic/`RefSink`
18//! sinks) *and* by `bynk-emit`'s TypeScript lowering (throwaway sinks, needed
19//! only for the resolved-type view a body's emission depends on). Dual-use
20//! functions are `pub`, and `bynk-emit` calls them qualified
21//! (`bynk_check::test_suites::foo(...)`) rather than duplicating them — see
22//! [`build_privileged_resolved`], [`typecheck_case_body`],
23//! [`check_history_binding`], [`register_call_record_types`],
24//! [`history_handlers`], [`history_variant_name`], [`prop_binding_generable`]
25//! and [`infer_participants`] for which and why (each names its own emit-side
26//! call sites). Duplicating a dual-use function instead of relocating it is
27//! exactly the drift risk this whole design track exists to close (§9,
28//! "Relocating checks risks a quiet R4.6/R4.11 regression").
29//!
30//! **What stayed in `bynk-emit`** (pure TypeScript emission, or verified
31//! emit-only by call-site count): `block_uses_observation`,
32//! `target_service_handler_kinds`, `is_attackable_contract`,
33//! `numeric_or_scalar_base`, `attackable_contracts`,
34//! `json_codec_qual_for_target`, `prop_history_binding`, `prop_is_history`,
35//! `SystemCaseInput`, `RunnableTest`, `discovered_location`,
36//! `discovery_manifest`, `sanitise_suite`, `emit_integration_module` and its
37//! http-driver/harness helpers, and the ~2,600-line TypeScript-codegen tail
38//! starting at `emit_test_module` (`emit_stub_class`, `gen_ts_for_ty`,
39//! `emit_test_property_function`, `emit_test_history_property_function`, and
40//! the rest).
41//!
42//! `bynk-emit/src/project/tests_emit.rs`'s own `process_tests`/
43//! `process_integration_tests` keep their exact signatures (`run_checks`'s
44//! callers need no change) — their bodies now call
45//! [`phase_test_bodies`]/[`phase_integration_bodies`] for the checking half,
46//! then proceed to their existing, unmoved Phase-5 emission logic using the
47//! "ready for emission" data these return.
48//!
49//! `bynk-emit` depends on `bynk-check` (a production dependency, never the
50//! reverse), so this move has no circular-dependency subtlety to solve —
51//! unlike P5.3's `phase_platform_lock`, which needed a from-scratch pure
52//! reimplementation because its old home reached into a `bynk-emit`
53//! TypeScript-codegen helper. This is a plain code-motion job, just a large
54//! one.
55
56use 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/// Which runs of a target's test module a `stub` clause applies to (#291).
76/// Every suite targeting a unit is emitted into one module, so a clause's
77/// scope is narrowed to its own suite whenever another suite shares the
78/// module (#1860).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum StubScope {
81    /// Every run: a suite-scoped clause of the target's only suite.
82    Every,
83    /// The run of the case of this name (a case-scoped clause). Case names
84    /// are unique across a target's suites (`bynk.suite.duplicate_case_name`).
85    Case(String),
86    /// Every run of the suite with this ordinal among the target's suites
87    /// ([`suite_ordinal`]): its cases and its properties. A suite-scoped
88    /// clause, when the target has more than one suite.
89    Suite(usize),
90}
91
92/// #1860: the ordinal of the suite at `parsed[i]` among the suites in
93/// `indices` (each index whose file is a suite), or `None` when `i` is not
94/// one. [`StubScope::Suite`] is keyed by it, so emission computes it the same
95/// way for each run.
96pub 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/// v0.118: a capability seam with one or more `stub` overrides applied
104/// (testing track slice 6). Groups every `stub Cap.method(…)` clause — both
105/// suite-scoped and case-scoped — targeting the same capability `cap`. The
106/// resolved [`CapabilityDecl`] supplies each overridden method's parameter names
107/// and return type for stub emission.
108#[derive(Debug, Clone)]
109pub struct ResolvedStub {
110    /// The capability being overridden (a declared/consumed seam of the target).
111    pub cap: String,
112    /// The capability declaration, for op parameter names and return types.
113    pub cap_decl: CapabilityDecl,
114    /// The `stub` clauses for this capability, in match order (case-scoped
115    /// first so they take precedence over suite-scoped in the emitted if-chain).
116    pub clauses: Vec<StubClause>,
117    /// #291: parallel to `clauses` — which runs each clause applies to.
118    pub clause_scopes: Vec<StubScope>,
119    /// The test file declaring the first clause — the recording context for
120    /// edges in its value expressions (v0.25).
121    ///
122    /// ADR 0198/0201: a *recording context* is an index key, so this is the
123    /// file's **identity** (project-relative), not its `include`-root-relative
124    /// unit path. Everything the index keys must name a file the round
125    /// analysed.
126    pub identity_path: PathBuf,
127}
128
129/// P5.4 (`design/tracks/semantics-in-the-checker.md` §6): the checking half
130/// of `test <target>` suite processing — target resolution, duplicate-case-
131/// name detection, `stub`-clause resolution, and case/property body
132/// type-checking. Formerly Phases 2-4 of `bynk-emit`'s own `process_tests`;
133/// Phase 5 (TypeScript emission) stays in
134/// `bynk-emit::project::tests_emit::process_tests`, which calls this
135/// function for its checking half and then emits only for the targets this
136/// returns — every target this function resolves, has no duplicate case
137/// names, and whose bodies type-check clean is exactly "ready for
138/// emission". `bynk_check::analysis::analyse_project` calls this too and
139/// discards the returned map — it never emits, so only the diagnostic/
140/// `RefSink` side effects matter there. Closes category 7 of
141/// `bynk-check/src/analysis.rs`'s own residual-gap accounting, alongside
142/// [`phase_integration_bodies`].
143#[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        // -- Phase 2: target resolution --
165        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        // -- Phase 2: duplicate test case names --
186        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        // -- #2134: each suite's and case's `env` clauses. --
212        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        // -- Phase 3: resolve `stub` clauses (v0.118, testing track slice 6).
219        // Both suite-scoped and case-scoped `stub` fold into one per-seam
220        // override map. Case-scoped clauses are collected first so they take
221        // precedence over suite-scoped ones in the emitted first-match if-chain
222        // (the case > suite > default order). Each clause applies only to its
223        // own case's run, or its own suite's cases (#291, #1860). Runs
224        // unconditionally, even when `had_dup` — its own diagnostics still
225        // fire, matching `process_tests`'s original Phase 2/3 ordering.
226        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            // Skip body/type-checking for this target; we have name conflicts.
238            continue;
239        }
240
241        // -- Phase 4: type-check bodies. --
242        // (We build a resolved view targeting either commons or context;
243        // mock bodies are type-checked with the mocked entity's privileges.)
244        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
271/// v0.118: resolve every `stub Cap.method(…)` clause targeting a unit into a
272/// per-capability [`ResolvedStub`] (testing track slice 6, ADR 0154). Both
273/// suite-scoped and case-scoped clauses fold in; a capability that is neither a
274/// declared seam of the target nor reachable through a consumed context is
275/// `bynk.stub.not_a_seam`, an unknown method is `bynk.stub.unknown_op`,
276/// and an empty `returns each []` is `bynk.stub.bad_sequence`.
277fn 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    // Collect clauses tagged with the declaring file and the cases each
290    // applies to. Case-scoped first so they precede suite-scoped clauses in
291    // each capability's match order.
292    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    // #1860: a suite-scoped clause applies to its own suite's runs, which
306    // matters only when another suite targets the same unit: all of them are
307    // emitted into one module.
308    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    // Resolve a capability name to its declaration: a capability the target
324    // declares (or has flattened in via `consumes U { Cap }`), else a capability
325    // of a consumed context.
326    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            // Commons have no seams at all; contexts may still name a
343            // non-existent capability. Either way it is not a seam.
344            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        // #926 (Decision F): a generic capability operation cannot be stubbed
371        // — `__Stub_Cap`'s per-op method body has no way to construct a
372        // value of the op's unconstrained `T`. Deferred rather than
373        // supported: the stub class carries no `implements` clause (its
374        // members are duck-typed through an untyped `deps` seam), so
375        // stubbing another, non-generic op of the same capability keeps
376        // type-checking.
377        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
419/// v0.118: infer a `system`-tier suite's wired participants — the target's
420/// transitive `consumes` closure (testing track slice 6). A BFS from the target
421/// following `consumes` edges; the returned list starts with the target and
422/// includes every context reachable through it (deterministic breadth order).
423/// #1856: contexts only. A unit consumed for its capabilities alone
424/// (`consumes bynk { Clock }`, an adapter) is provided in-process by the
425/// composition root: no Worker, no Service Binding, no wire to cross. The
426/// walk still passes through one, but it is no participant.
427pub 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
453/// #1959: the contexts a `system`-tier suite's harness stands up as Workers:
454/// [`infer_participants`]' set, plus every context subscribing to an event
455/// one of them declares, plus *its* `consumes` closure, transitively. A
456/// subscriber consumes its publisher, and `consumes` cycles are refused, so
457/// no subscriber of the target is ever in the target's own closure; without
458/// it here, the fan-out would deliver to nobody. Only the harness widens: a
459/// case still reaches just [`infer_participants`]' set. Target first, then
460/// breadth order.
461pub 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/// P5.4 (`design/tracks/semantics-in-the-checker.md` §6): the checking half
492/// of `test integration "name"` suite processing — participant inference,
493/// the `system`-needs-a-serialisation-edge gate, duplicate-case-name
494/// detection, the harness-root cross-context view, and per-case body
495/// type-checking (including the `Wire`/`by Nobody` tier gates). Formerly the
496/// pre-emission logic of `bynk-emit`'s own `process_integration_tests`;
497/// emission stays in `bynk-emit::project::tests_emit::process_integration_tests`,
498/// which calls this function for its checking half and then emits only for
499/// the groups this returns. Unlike [`phase_test_bodies`]'s `ResolvedStub`
500/// map, the only thing worth handing back here is the harness's
501/// [`resolver::CrossContextInfo`] — it's built from clone-heavy maps
502/// (`harness_consumes`/`harness_uses`), so recomputing it a second time on
503/// the emit side would be wasted work. `participants`/`uses_targets`/
504/// `case_inputs` are cheap and pure (a BFS, a linear scan), so the emit-side
505/// loop recomputes those itself from `parsed`/`unit_consumes`, using the
506/// now-relocated [`infer_participants`]. `bynk_check::analysis::analyse_project`
507/// calls this too and discards the returned map — it never emits. Closes
508/// category 7 of `bynk-check/src/analysis.rs`'s own residual-gap accounting,
509/// alongside [`phase_test_bodies`].
510#[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        // v0.118: there is no `suite` string any more — the wired suite is named
535        // for its target context. The participant set is INFERRED from the
536        // target's transitive `consumes` closure (no `wires` list).
537        let suite_target = decl.target.joined();
538        let participants = infer_participants(&suite_target, unit_consumes, kinds);
539
540        let mut bad = false;
541
542        // v0.118 / testing-the-boundary Slice B: a `system` suite needs a real
543        // **serialisation edge** — not merely ≥ 2 participants. The original rule
544        // (`participants.len() < 2`) was a proxy for "nothing to serialise
545        // across", exact only when the sole edge was cross-context. A single
546        // context that exposes an `http` service has a real edge (the public
547        // boundary: deserialise → handler → serialise), so it qualifies.
548        //
549        // `http` and `queue` are admitted, because the system driver wires both:
550        // `emit_system_http_support` drives `worker.fetch`, and
551        // `emit_system_job_support` (#2136) drives `worker.queue` with a batch
552        // whose message body has been through the platform's JSON
553        // serialisation. `cron` never qualifies on its own — a scheduled event
554        // carries only a cron string and a time, so there is nothing to
555        // serialise — but a cron address is legal in a suite that already
556        // qualifies, and is driven through `worker.scheduled` there.
557        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        // -- Duplicate case names within the suite. --
583        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        // -- #2134: each suite's and case's `env` clauses. --
609        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        // -- #1738: a `system` case can't share a suite with lower-tier ones. --
616        // Reported without marking the suite `bad`: the per-case tier gates
617        // below (`Wire`, `by Nobody`) still check its bodies, and any error
618        // already stops emission.
619        // A suite with any `system` case is emitted, whole, as the wired module
620        // that drives deployed Workers, where a service is addressed by its
621        // context path (`shop.orders.place(…)`), not `place.call(…)`. A lower-
622        // tier case there has no in-process target to call (it lowered to
623        // `/* unknown */`), so the tiers are kept to separate suites.
624        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                // In a `system` suite, flag each case that steps down; in any
639                // other suite, flag each case that steps up to `system`.
640                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        // -- #2026: a `stub` has nothing to override at `system`. --
680        // Each participant runs as the Worker it deploys as, and its
681        // composition root builds every capability provider from its own
682        // `env`, so the harness has no seam to put a double in. Refused
683        // rather than accepted and dropped. Reported without marking the
684        // suite `bad`, like #1738 above.
685        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        // -- Build the harness-root cross-context view (consumes all). --
725        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        // -- Type-check each case body. --
750        let mut body_errs: Vec<CompileError> = Vec::new();
751        // #1814: the harness view every case is checked in, closed over the
752        // types its declarations reach.
753        let view = integration_view(&uses_targets, &participants, unit_tables, unit_uses);
754        // Review of #1814: the harness pairs the suite's own `uses` with the
755        // participants, which no unit's closure sees together, so a reached
756        // name one of them already binds is reported here, at the suite's
757        // `uses` the reaching declaration came through (or its target, for a
758        // participant's).
759        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        // v0.25: the harness root is a synthetic namespace — declare its
775        // resolution order (uses first, then participants) for assembly.
776        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            // #1814: a reached type resolves in the view, but the suite may
789            // not name it.
790            body_errs.extend(resolver::check_hidden_type_names_in_suite(
791                d,
792                None,
793                &view.hidden,
794            ));
795            // #2132: the target's services and actors, to tell which route a
796            // `system` case's address reaches and how that route is secured.
797            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                // Slice C: `Wire(…)` is a `system`-only raw argument (it drives the
832                // real wire); in a non-`system` case it has no wire to be raw
833                // about, so lowering it would silently pass raw text to a direct
834                // in-process handler call. Reject it at the tier where it is known.
835                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                // #706: `by Nobody` presents no credential to the real auth seam
852                // (the 401 path), which exists only at `system`; at a lower tier
853                // the handler just runs with no identity, silently not a 401.
854                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/// Type-check one integration test case body. The body lives in a synthetic
886/// harness root that consumes every participant; entry calls
887/// (`ctx.service(args)`) are therefore ordinary cross-context calls. The body
888/// has type `Effect[Result[(), ExpectationError]]` (modelled as
889/// `Effect[Result[(), ValidationError]]`, as in unit tests).
890///
891/// Returns the body's `expr_types` and `Callee` classification. As
892/// [`typecheck_case_body`] does for the unit tier, the **check** path feeds
893/// real sinks and the **emit** path reuses it with throwaway ones. #1855: the
894/// emit path once lowered with no types at all, so a literal at a refined
895/// record field was never branded and the test module failed `tsc`.
896#[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    // Names in scope: the harness view (#1814: closed over reached types).
911    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    // `synthetic_commons` declares nothing of its own (`items: Vec::new()`
935    // above) — every entry in `types`/`fns`/`methods` was merged in from a
936    // `uses`/participant unit, so an empty local table (no local types, no
937    // local events) is the correct answer here, not a stand-in for one.
938    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        // Test-scaffold body, not a real context emission — never rebranded.
951        false,
952        HashSet::new(),
953    )
954    // #1883, slice 3: the assets the case may name and fetch.
955    .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    // Test bodies record no hints (out of v0.27 scope) — a throwaway sink.
971    let mut no_hints = HintSink::new();
972    let mut no_locals = LocalsSink::new();
973    // Test bodies record no capability requirements either — muted sink.
974    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        // Slice B: a `system` case addresses the target's own service (`api.POST`)
983        // and names a principal (`by User(...)`), so the checker needs the
984        // target's services and actors — the same resolution the unit tier does.
985        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
1006/// Review of #1814: `bynk.uses.name_conflict` for a reached name a suite's
1007/// view binds to another declaration, at `span`. `names` says what the view
1008/// binds the name to. The same diagnostic a unit's closure gives (#1807).
1009fn 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/// Type-check test/property bodies for a target and validate `stub` RHS
1039/// value types (v0.118). Bodies use the target's privileged view; a `stub`
1040/// value whose type disagrees with the overridden op's return is
1041/// `bynk.stub.rhs_type`.
1042#[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    // The target's privileged view, built once for the `stub` check and the
1060    // #1814 naming gate and conflict report below.
1061    let privileged = build_privileged_resolved(
1062        target_name,
1063        unit_tables,
1064        unit_uses,
1065        unit_consumes,
1066        unit_consumes_aliases,
1067    );
1068
1069    // v0.118: validate each `stub` RHS value's type against the overridden
1070    // op's declared return type, in the target's privileged view. A best-effort
1071    // check: the value expression is type-checked as if it were the op body's
1072    // tail; any resulting error surfaces as `bynk.stub.rhs_type`.
1073    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    // #1737: which target services reach another context, for the tier gate
1117    // in the loop below. Computed once per target.
1118    let crossing = CrossingServices::of(
1119        target_name,
1120        unit_tables,
1121        unit_consumes,
1122        unit_consumes_aliases,
1123    );
1124
1125    // #1814: the types the target's privileged view reaches but does not
1126    // name. A case resolves them; no suite may write one.
1127    // #1927: the imported types the target hides behind its own values,
1128    // with the target's types to find a shadowed sum's variants.
1129    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    // Review of #1814: a reached name the view binds to a consumed context's
1137    // unexported type. The target's own table holds only that context's
1138    // exports, so its closure resolved the name to the reached commons type
1139    // and reported nothing; every other conflict here is the target's own,
1140    // which its closure already reported as `bynk.uses.name_conflict`.
1141    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    // Type-check test case bodies — they live in the target's privileged
1161    // view, with `stub` overriding individual capability seams.
1162    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        // #1927: nor a type the target hides behind its own value.
1172        errors.extend(resolver::check_shadowed_type_names_in_suite(
1173            test_decl,
1174            &target_shadowed.0,
1175            &target_shadowed.1,
1176        ));
1177        // v0.25: test-case edges record in the test file, resolving bare
1178        // names through the *target* unit's namespace.
1179        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        // v0.114: generative `property` blocks — check their `for all` bindings,
1205        // `where` filter, and predicate body (testing track slice 2).
1206        // v0.118: a `property` never carries a tier — `as <tier>` is a
1207        // `case`-only affordance, and the grammar has no property-tier
1208        // production (`PropertyDecl` has no tier field), so there is nothing to
1209        // check here (#1662 removed an unreachable guard for it).
1210        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
1231/// #2137: `advance Clock by …` moves a `case`'s harness clock. A `property`
1232/// runs its body once per generated input with no clock of its own, so an
1233/// `advance` there is `bynk.test.advance_outside_case`, as it is anywhere else
1234/// outside a `case` body.
1235fn 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    // Descend through every expression, entering a nested block through
1255    // `collect` so its statements are seen once.
1256    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
1306/// v0.118: wrap a single expression as a `{ tail: e }` block, so a `stub`
1307/// value can be type-checked or lowered in the same op-body position a provider
1308/// operation's tail occupies.
1309///
1310/// Dual-use (found during P5.4's move, not in the original slice plan):
1311/// `stub_value_typechecks` (in this module) uses it for the checking path;
1312/// `bynk-emit`'s `lower_stub_value_block` also calls it, qualified, to lower
1313/// a `stub` RHS value in the same op-body tail position. `pub` for that
1314/// second caller, same as every other dual-use function in this module.
1315pub 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
1325/// v0.118: whether a `stub` value expression type-checks against the
1326/// overridden capability op's declared return type (best-effort — a throwaway
1327/// check against the target's privileged view). A mismatch drives
1328/// `bynk.stub.rhs_type`.
1329fn 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
1356/// Slice C: whether a `case` body uses a `Wire(…)` raw argument anywhere. A
1357/// `Wire` is only meaningful at `system` (it hands pre-validation input to the
1358/// real boundary); used at any other tier it is `bynk.test.wire_needs_system`.
1359fn block_uses_wire(block: &Block) -> bool {
1360    // Ported onto `bynk_syntax::ast::expr_children` (P5.4) — `bynk-emit`'s
1361    // `crate::emitter::walk_exprs` this used before the move is emission-
1362    // private and unreachable from `bynk-check`. `expr_children` is the
1363    // exhaustive total child iterator the checker already walks the same way
1364    // (see `context_checks.rs`/`checker.rs`); this reimplements the original
1365    // statement-value + tail walk faithfully, not a rewrite of its behaviour.
1366    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    // #1851: every expression of a statement, a principal's identity
1373    // included, not a hand-picked value.
1374    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
1381/// A cross-context service call: `(context, service)`.
1382type CrossCall = (String, String);
1383
1384/// A same-context agent handler: `(agent, handler)`.
1385type AgentHandler = (String, String);
1386
1387/// #1737: what in the target reaches another context, for the
1388/// `bynk.tier.cross_context_needs_system` gate: each service, and each agent
1389/// handler, that does, with the cross-context call it reaches.
1390///
1391/// Below `system` a case runs in-process: a consumed context is not stood up,
1392/// and `stub` doubles capabilities only, never a context's services. So a case
1393/// that reaches another context's service (directly, or through a target
1394/// service or agent handler that calls one) had no collaborator to call, and
1395/// crashed at runtime on an `undefined` surface. Crossing a context boundary is
1396/// the `system` tier's job; this finds the test bodies that try it below.
1397///
1398/// A cross-context call is recognised the way the resolver resolves one
1399/// (`CrossContextInfo::resolve_prefix`): a method call whose receiver chain is a
1400/// consumed context's alias or qualified name, naming a service that context
1401/// declares. A service or agent handler reaches another context if its own
1402/// body makes such a call, or dispatches to an agent handler that
1403/// (transitively) does. An agent call is recognised inline
1404/// (`Counter(k).bump(…)`) and through a `let`-bound instance
1405/// (`let c = Counter(k)` then `c.bump(…)`).
1406struct CrossingServices<'a> {
1407    /// service name → the cross-context call it reaches.
1408    services: HashMap<String, CrossCall>,
1409    /// agent handler → the cross-context call it reaches.
1410    agents: HashMap<AgentHandler, CrossCall>,
1411    /// The target's own table, for recognising agent calls in a test body.
1412    table: Option<&'a UnitTable>,
1413    /// The target's consumed contexts and aliases, for direct calls in a body.
1414    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        // Each agent handler's own cross-context call (if any) and the agent
1444        // handlers it dispatches to, then a fixpoint over the dispatch edges.
1445        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    /// The consumed context `chain` names, by alias or qualified name.
1488    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    /// `e` as a cross-context service call.
1495    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    /// `e` as an agent handler call, inline (`Counter(k).bump(…)`) or through
1511    /// an instance `bound` by a `let` (`c.bump(…)`).
1512    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    /// The first cross-context call in `block` (in source order), and every
1531    /// agent handler it dispatches to.
1532    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    /// The names `block` binds, at any depth, to an agent instance
1548    /// (`let c = Counter(k)`), mapped to the agent.
1549    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    /// Report each call in a non-`system` test body that reaches another
1568    /// context: a target service that crosses (`check.call(…)`), an agent
1569    /// handler that crosses (`Counter(k).bump(…)`), or a consumed context's
1570    /// service called directly. `subject` names the body (``case `"…"` ``),
1571    /// and `below` says why it runs in-process.
1572    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        // A name a `let` in the body rebinds is the binding, not a service.
1585        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            // #1883 (review of #1923): `svc.<VERB>(x)` whose first argument is
1604            // not a route string reaches no handler — it is an asset fetch
1605            // (`svc.GET(asset)`, served by the asset tier) or a bad address
1606            // reported elsewhere — so it crosses into no other context.
1607            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                // `svc.call(…)` / `svc.GET(…)` / … on a target service.
1616                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    /// The gate for a non-`system` `case`.
1664    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        // "an integration", but "a unit" (said with a "y" sound).
1673        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    /// The gate for a `property`, which has no tier and always runs in-process.
1684    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
1700/// `e` as a dotted name (`Vault`, `demo.vault`), or `None` for anything that
1701/// is not a chain of identifiers.
1702fn 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
1712/// Every expression in `block` at any depth, in source order: each statement's
1713/// expressions, then the tail, each visited before its children (via
1714/// [`bynk_syntax::ast::expr_children`]).
1715fn 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    // A stack popped from the end, so push in reverse to visit in order.
1722    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
1731/// `block` and every block nested in it (a block expression, an `if`'s
1732/// branches, a `match` arm's block body), so a `let` at any depth is seen.
1733fn 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
1759/// #1706: whether a `case` body claims a fault anywhere (`expect <call>
1760/// faults`). The claim observes a call *throwing*, which only an in-process
1761/// call does: at `system` a handler's fault crosses the real Worker boundary
1762/// as an error response, never a throw at the harness, so the claim could
1763/// never hold there (`bynk.test.faults_needs_in_process`).
1764fn block_uses_faults(block: &Block) -> bool {
1765    !faults_claims(block).is_empty()
1766}
1767
1768/// Every fault claim (`expect <call> faults`) in `block`, at any depth, in
1769/// source order.
1770fn 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
1791/// #2052: report a `system`-tier case that delivers an event with
1792/// `svc.event(e)`. At `system` an event reaches a subscriber only through its
1793/// publisher's fan-out, which emits to every subscriber of the event rather
1794/// than addressing one service, over a per-edge signed request. A case-built
1795/// delivery to one subscriber has no counterpart there, so the address is an
1796/// in-process one: the case drives the emitting handler instead.
1797fn 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
1834/// #2138 (end-to-end testing track §3.7): what a remote run can't honour.
1835/// Under `bynk test --against`, a `system` case runs against a real
1836/// deployment, which only answers on its public `http` routes. Returns the
1837/// `bynk.test.remote_unsupported_*` refusal for `case`, given the callees its
1838/// body was checked with, or `None` when it uses only `http` addresses.
1839///
1840/// Refused, in this order (the first that applies is reported):
1841/// - an `on call` address, its own or another context's — internal, and
1842///   signed per pair (#2004, #2012);
1843/// - `advance Clock` — the deployed Workers read wall-clock time (§3.4);
1844/// - `svc.schedule(…)` and `q.message(…)` — no public wire reaches a cron
1845///   handler or a queue consumer;
1846/// - an asset fetch (`svc.GET(asset)`) — answered in process from the files
1847///   the target ships, not by the deployment.
1848///
1849/// The track's other two refusals, a direct agent read and `svc.event(…)`,
1850/// are already refused at the `system` tier itself (an agent isn't in a
1851/// `system` case's scope; `bynk.test.event_needs_in_process`), so a remote
1852/// build never sees them.
1853pub 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    // `callees` is a hash map: pick deterministically among several hits.
1876    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
1938/// #2138 (track §3.4): `bynk.test.remote_clock_dependent`, a warning for a
1939/// remote run. In process every `Clock` reads the harness clock; a real
1940/// runtime's Workers read wall-clock time, which the runner can't reach. So an
1941/// `expect` comparing an `Instant` built from constants can pass in process
1942/// and fail remotely, and no `--settle` retry fixes that.
1943///
1944/// An expression is *constant* when nothing the run produced flows into it:
1945/// literals, type and static receivers (`Instant.fromEpochMillis(0)`), calls
1946/// whose receiver and arguments are all constant (`Clock.now()` included: it
1947/// reads the runner's clock, not a Worker's), and `let`s bound to constants.
1948/// Anything reached from an address's result (a `<-` binding, a pattern or
1949/// lambda binding, a field of one, a call on one, or such a value shifted by
1950/// a `Duration`) is *run-derived*. A comparison with an `Instant`-typed
1951/// constant side warns; `expect o.expires > o.created` doesn't.
1952///
1953/// Every `expect` in the case is scanned: top-level, in `if` and `match`
1954/// arms, in nested blocks and in the tail (where a `let … else` puts the
1955/// rest of the case). `let` constness is tracked by name through the case in
1956/// source order. #2181: a `match` arm's pattern, a lambda parameter and an
1957/// `is` test's bindings rebind a name to a run-derived value, so each clears
1958/// it from the constants for its scope (an `is` binding for the whole
1959/// predicate or `if` it sits in, which errs towards not warning). Reported
1960/// once per case, at the first such `expect`.
1961pub 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        /// The `let` names bound to a constant.
1970        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                // A type or static receiver (`Instant`, `Clock`) is constant;
1982                // a lowercase name is constant only if a `let` bound it to one.
1983                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        /// Run `f` with `names` cleared from the constants, then restore them.
1991        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        /// An `expect`'s predicate: any comparison with a constant `Instant`.
2000        /// The binders inside it (an `is` pattern, a lambda parameter, a
2001        /// `match` arm) rebind their names for the whole predicate.
2002        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        /// Find the blocks (and `expect` expressions) nested in `e`.
2056        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
2131/// #2181: the names `e` itself binds for a scope under it: an `is` test's
2132/// pattern, a lambda's parameters, a `match`'s arm patterns.
2133fn 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
2153/// Visit `e` and every expression under it — the deep walk over
2154/// [`expr_children`]. (`bynk_ir::walk_exprs` is the same walk, but `bynk-ir`
2155/// depends on this crate, so it can't be reused here.)
2156fn 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
2163/// #2138: whether `block` (or a block nested in it) holds an `advance`.
2164fn 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
2201/// #2150: report a `system`-tier case that calls an http route whose actor is
2202/// `Oidc`-secured. The system driver presents a credential for a Bearer
2203/// actor, signs for a `Signature` actor (#2135), and on an actor sum picks the
2204/// credential from the call-site principal (#2155, which retired #2132's
2205/// `bynk.test.signature_needs_in_process`), but it has no `Oidc` token issuer
2206/// yet: the request reaches the real seam without a token and can only be
2207/// refused with a `401` (`bynk.test.oidc_needs_in_process`). ADR 0212 D1: the
2208/// check gates exactly what the emitter emits. A call driven `by Nobody` (or
2209/// `by Forged`/`by Stale`) is left to the checks of those principals.
2210fn 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    // The call-site principal of each `let x <- <address> by <Actor>`.
2220    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    // The schemes the system driver has no credential for, as the label
2231    // that picks the diagnostic below.
2232    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        // A sum runs as the member the call-site principal names. With no
2267        // principal the system driver falls back to the primary member, as
2268        // the sum's primary member, so an Oidc primary still goes out without a
2269        // credential. A principal naming no member is
2270        // `bynk.test.principal_identity_mismatch`'s, not this check's.
2271        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
2312/// #1706: report a `system`-tier case that claims a fault — see
2313/// [`block_uses_faults`].
2314///
2315/// #1812: a fault claim driven `by Nobody` is reported too, at any tier.
2316/// `by Nobody` exercises the auth seam that exists only at `system`, where
2317/// the claim cannot hold, so it falls under the same code rather than
2318/// `bynk.test.credential_needs_system`, whose advice (promote the case to
2319/// `system`) would only lead here.
2320fn 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            // #2135: `by Forged` / `by Stale` likewise drive only the
2332            // `system`-tier seam.
2333            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
2369/// #706: whether a `case` body drives an effect-let `by Nobody` — the "no
2370/// credential" principal. It is only meaningful at `system` (there is no auth
2371/// seam to reject a missing credential at `unit`), so a non-`system` case using
2372/// it is `bynk.test.credential_needs_system`. Every block of the body counts,
2373/// not just its top level (#1849): a `by Nobody` inside an `if` or a match arm
2374/// is the same credential-less call.
2375fn 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
2384/// #2135: whether `name` is a reserved bad-signature principal (`by Forged`,
2385/// `by Stale`).
2386fn is_bad_signature_name(name: &str) -> bool {
2387    matches!(name, "Forged" | "Stale")
2388}
2389
2390/// #2135: `bynk.test.forged_needs_system` — a case below `system` that drives
2391/// `by Forged` or `by Stale`. Only the `system` driver signs a request, so
2392/// only there does the real `Signature` seam see the bad signature; in
2393/// process the seam is skipped and the handler would just run. Every block of
2394/// the body counts, as for `by Nobody` (#1849).
2395fn 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
2435/// Register a synthetic call-record type per capability operation of the target
2436/// context (v0.117, testing track slice 5), so `trace(Cap.op)` — typed
2437/// `List[<CallRecord>]` — supports field access on its records. The record's
2438/// fields are the operation's parameters.
2439pub 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    // #291: a consumed adapter's capabilities are seams too (its `trace` and
2448    // `with` records); the target's own wins a name clash.
2449    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
2497/// #2052 review: the context that publishes `event` to `target`'s
2498/// subscribers, resolved as the target's `from Events(E)` header resolves
2499/// `E`: the target's own declaration, else the one context among those it
2500/// consumes (`consumed`) that declares it. `Err` lists the candidates when
2501/// there is no unique one (none, or several). A same-named event in a context
2502/// the target does not consume is never a candidate.
2503pub 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    // #2052: the events in the target's scope, its own and its consumed
2543    // contexts', for an events service's `bynk.test.event_not_subscribed`.
2544    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
2594/// #291: the platform capabilities the target flattens in from a consumed
2595/// adapter (`consumes bynk { Logger }`) — seams of the unit under test, like
2596/// the capabilities it declares — with their declarations, sorted by name.
2597fn 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
2617/// #291: a platform capability the target flattens in from a consumed
2618/// **adapter** (`bynk`'s `Logger`, by `consumes bynk { Logger }`) is a seam of
2619/// the unit under test too, so a test body may observe it (`expect Logger.info
2620/// called once`), as it may already `stub` it. A capability the target declares
2621/// itself wins on a name clash.
2622fn 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/// Type-check a test `case`/`property` body against the target unit's privileges,
2649/// returning the inferred `expr_types` map and the `Callee` classification
2650/// recorded alongside it. The **check** path feeds real diagnostic/ref sinks;
2651/// the **emit** path reuses it with throwaway sinks to give the case-body
2652/// lowering full type information (so collection kernels — notably
2653/// `trace(Cap.op)`'s `List[…]` methods — dispatch on the receiver's checked
2654/// type) *and* full `Callee` information (P6.21 review: the emit path's own
2655/// `callees` accumulator was previously built here and silently discarded —
2656/// `bynk-emit`'s `synthetic_typed_commons_for_target` never received it, so
2657/// `Callee::Intrinsic`/`Store`/etc. were never recorded for anything inside a
2658/// `.test.bynk` body, even though this function computed them correctly all
2659/// along).
2660#[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    // v0.119: bindings already in scope for the body — empty for a `case`, the
2670    // `run: List[Step]` binding for a history property.
2671    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    // Synthesise an Effect[Result[(), ValidationError]] return type as a
2680    // stand-in for Effect[Result[(), ExpectationError]]. v0.7 doesn't model an
2681    // explicit ExpectationError type — the runtime catches it instead.
2682    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    // Capabilities of the target context, if any (so the test body can
2692    // call capabilities directly when targeting a context).
2693    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    // All declared capabilities are implicitly "given" inside a test body;
2714    // the test runner wires them via the mocked deps. We feed the same map
2715    // to both `capabilities` (in-scope) and `declared_capabilities`.
2716    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    // Test bodies record no hints (out of v0.27 scope) — a throwaway sink.
2721    let mut no_hints = HintSink::new();
2722    let mut no_locals = LocalsSink::new();
2723    // Test bodies record no capability requirements either — muted sink.
2724    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    // Don't enforce return-type equality; the test runner discards the
2796    // tail expression and recovers success/failure from expectation outcome.
2797    // Don't enforce "every given used" — capabilities are implicitly
2798    // available in a test body.
2799
2800    // v0.115: flag a `case` that merely restates a contract already declared at
2801    // the source (`bynk.contract.restated_by_test`) — an `expect` that is
2802    // α-equivalent to an `ensures` clause over the same bound arguments. The dev
2803    // guard and the runner attack already check it. Conservative: under-flagging
2804    // is acceptable, over-flagging is not.
2805    check_restated_contract(&case.body, &resolved, errors);
2806}
2807
2808/// v0.115: within a test body, flag an `expect` that re-states a contract's
2809/// `ensures`. Fires only on the clearest restatement: a binding `let r = f(args)`
2810/// (or `r <- f(args)`) of a contracted free function's result, followed by an
2811/// `expect E` that is α-equivalent to one of `f`'s `ensures` predicates under the
2812/// substitution `result → r`, `params → args`. Syntactic — never semantic — so a
2813/// merely-equivalent (but differently written) test is not flagged.
2814fn check_restated_contract(
2815    body: &Block,
2816    resolved: &ResolvedCommons,
2817    errors: &mut Vec<CompileError>,
2818) {
2819    // Map each locally-bound name to the contracted free function + call args it
2820    // was bound from (`let r = f(a, b)`).
2821    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            // subst: result → r, each param → its call argument.
2845            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
2883/// Structural (α-)equality of two predicate expressions, ignoring spans, where a
2884/// bare identifier in `pattern` that appears in `subst` must match the
2885/// corresponding substituted expression in `actual` (the rest compares by shape).
2886/// Deliberately conservative — only the operators/leaves a contract predicate can
2887/// contain are compared; anything unrecognised is unequal.
2888fn 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
2933/// Plain structural equality of two expressions ignoring spans — used to compare
2934/// a substituted argument against its use in the test predicate.
2935fn 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
2970/// v0.114: the recursion cap for property-binding generability (mirrors the
2971/// checker's `MOCK_DEPTH` for bare `Val`).
2972pub const PROP_GEN_DEPTH: u32 = 12;
2973
2974/// Whether a `for all x: T` binding's type is refinement-generable: refined
2975/// types must not carry a `Matches` predicate (no refinement-driven generator),
2976/// and sums/records must have every component recursively generable within the
2977/// depth cap. Mirrors the checker's `can_mock_bare`.
2978pub 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
3017/// The refinement of a resolved refined/opaque named type, if any — used by the
3018/// conservative restates-refinement check.
3019fn 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
3036/// v0.114 (DECISION P): does `pred` merely restate a refinement `bound_var`
3037/// already guarantees? A **conservative, syntactic** check — it fires only when
3038/// the predicate is exactly the refinement over the bound variable, never
3039/// guessing (under-flagging is acceptable; over-flagging is not). Handles the
3040/// `Positive` (`v > 0` / `v >= 1`) and `NonNegative` (`v >= 0`) numeric cases.
3041fn 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    // `<var> <op> <int-literal>` only.
3046    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        // `v > 0` / `v >= 1` restate `Positive`.
3066        BinOp::Gt if n == 0 => positive,
3067        BinOp::GtEq if n == 1 => positive,
3068        // `v >= 0` restates `NonNegative`.
3069        BinOp::GtEq if n == 0 => non_negative,
3070        _ => false,
3071    }
3072}
3073
3074/// v0.119 (DECISION D): which state-projection rewrite maps a history predicate
3075/// back into the space an `invariant` / `transition` is written in.
3076#[derive(Clone, Copy)]
3077enum HistoryRestate {
3078    /// An `invariant` reads bare state fields: `s.new.F` ≡ `F`.
3079    Invariant,
3080    /// A `transition` reads `old` / `new`: `s.old` ≡ `old`, `s.new` ≡ `new`.
3081    Transition,
3082}
3083
3084/// `Some(field)` when `e` is `s.new.<field>` (the reached-state projection an
3085/// invariant-restating history predicate uses).
3086fn 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
3103/// `Some("old"|"new")` when `e` is `s.old` / `s.new` (the step projections a
3104/// transition-restating history predicate uses).
3105fn 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
3115/// Conservative, span-insensitive structural match (DECISION D): does the history
3116/// predicate `body` (over the step binding `s`) restate the declared predicate
3117/// `decl`, modulo the `mode` state-projection rewrite? Under-flags by design — any
3118/// construct not modelled here compares unequal, so a valid test is never blocked.
3119fn history_pred_matches(body: &Expr, s: &str, decl: &Expr, mode: HistoryRestate) -> bool {
3120    // Leaf equivalences the rewrite establishes.
3121    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        // #2018: `x is Variant` — a transition over an enum state (`old.status is
3146        // Closed implies new.status is Closed`) is written with `is`.
3147        (
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
3213/// #2018: span-insensitive equality of the pattern in an `is` test, for
3214/// [`history_pred_matches`]. Conservative like the rest of the restate check: only
3215/// a literal or a payload-free variant (same name, same optional `Type.`
3216/// qualifier) is compared; any other pattern shape compares unequal.
3217fn 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
3243/// v0.119 (DECISION D): a history property that merely restates a snapshot/step
3244/// invariant is redundant — the driver only commits states the invariants already
3245/// admit. Recognise the canonical shape `for all run: History[A] { expect
3246/// run.all((s) => P) }` (or `.any`) whose `P` α-matches a declared
3247/// `invariant` (over `s.new`) or `transition` (over `s.old`/`s.new`). Returns the
3248/// body span to flag. Conservative — near-duplicates slip through by design.
3249fn 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    // `run.all((s) => P)` / `run.any((s) => P)`.
3257    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 = &param.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
3295/// v0.119 (ADR 0155): the synthetic type names a `History[Agent]` binding
3296/// registers — a call sum, a step record, and a state record — all keyed off the
3297/// agent name so distinct agents never collide.
3298fn 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
3308/// The `.call` variant tag for a handler: the handler name with its first letter
3309/// upper-cased (`spend` → `Spend`, `topUp` → `TopUp`). The reader matches this
3310/// with `is` / `match` (`s.call is Spend`).
3311pub 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
3319/// The agent's drivable `on call` handlers — the ones a history sequences. Other
3320/// handler kinds (`http`/`cron`/`message`/`open`/`close`) are not RPC entry points
3321/// and are never part of a generated call-history.
3322pub 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
3330/// v0.119 (testing track slice 7, ADR 0155): type-check a `for all run:
3331/// History[Agent]` binding. The subject is a *run* of the agent — a generated,
3332/// driven call-history — bound as an ordinary `List[Step]`. Validates the
3333/// DECISION-B rules (agent-only, every handler parameter generable), registers the
3334/// synthetic call-sum / step / state record types into `resolved.types` so the
3335/// predicate's `List` + value surface (`.call is …`, `.old`/`.new`, `.accepted`)
3336/// type-checks, and returns the bound `List[Step]` type.
3337pub 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    // DECISION B: only an agent has handlers to sequence and reachable states to
3345    // observe. `History[Value]` / `History[List[…]]` is `not_an_agent`.
3346    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    // DECISION B: the agent must be *drivable* — every handler parameter must be
3374    // refinement-generable (the same rule a value `for all` binding obeys), else
3375    // the runner cannot synthesise a call.
3376    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    // Register the synthetic types (mirrors `register_call_record_types`). The
3400    // driver returns plain objects of exactly these shapes; the checker sees them
3401    // as ordinary record/sum types so `is`, field access, and `implies` apply
3402    // unchanged (the typed-step shape resolving the track's open question).
3403    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    // `<Agent>State` — the agent's `Cell` fields, exactly as the emitted state
3408    // record (so `.old.balance` / `.new.balance` read a reached state).
3409    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    // `.call` — a sum over the agent's handlers, each variant carrying the
3443    // handler's generated arguments (`Spend { amount }`, `TopUp { amount }`).
3444    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    // A `Step` — the driven edge: which call ran (`.call`), whether it committed
3490    // (`.accepted`), and the committed `old` → `new` state pair.
3491    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/// v0.114: type-check a generative `property` — its `for all` bindings, the
3577/// optional `where` filter, and the predicate body — in the target's privileged
3578/// view. Bindings type each `x: T`; `where`/`expect` predicates type as pure
3579/// `Bool`; each binding's `T` must be refinement-generable (agents are rejected;
3580/// a `Matches` type must pin); and the body is flagged if it merely restates a
3581/// refinement (DECISION P). v0.119: a `for all run: History[Agent]` binding is a
3582/// driven call-history (the history rung — see [`check_history_binding`]).
3583#[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    // Bind each `for all x: T` into the predicate scope, checking generability.
3609    let mut binding_scope: HashMap<String, checker::TyId> = HashMap::new();
3610    let mut binding_types: Vec<(String, Option<checker::TyId>)> = Vec::new();
3611    // v0.119: the single `History[Agent]` binding (run-var, agent), for the
3612    // post-body `restates_invariant` check (DECISION D).
3613    let mut history_binding: Option<(String, AgentDecl)> = None;
3614    for b in &prop.forall.bindings {
3615        // v0.119 (ADR 0155): `for all run: History[Agent]` — the history rung. A
3616        // driven call-history, bound as an ordinary `List[Step]`.
3617        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                    // #1708: rejected, but still in scope (error-typed), so
3632                    // its uses in the body do not echo as unknown names.
3633                    binding_scope.insert(b.name.name.clone(), tys.intern(checker::Ty::Error));
3634                }
3635            }
3636            continue;
3637        }
3638        // Agents are not a value type — a fabricated state that satisfies every
3639        // invariant need not be reachable (DECISION P); reject up front.
3640        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            // #1708: rejected, but still in scope (error-typed), so
3658            // its uses in the body do not echo as unknown names.
3659            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                // #1708: rejected, but still in scope (error-typed), so
3679                // its uses in the body do not echo as unknown names.
3680                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    // Type the `where`/body predicates in the target's privileged view with the
3703    // bindings in scope — mirroring the `case` body context.
3704    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    // The optional `where` filter is checked first (against `Bool`), sharing
3741    // `check_body`'s `Ctx` with the body below; the body is the one predicate
3742    // surface: `expect`s self-check as `Bool`.
3743    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    // Conservative restates-refinement flag: a single-binding property whose
3777    // body is exactly `expect <pred>` restating the bound var's refinement.
3778    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    // v0.119 (DECISION D): a history property that merely restates a declared
3801    // `invariant` / `transition` re-checks a guarantee every reached state already
3802    // has (the driver only commits admissible states). Conservative — near-
3803    // duplicates slip through by design.
3804    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
3823/// Record type references named by a `for all` binding so cross-file edges and
3824/// go-to-definition resolve for a property's generated types.
3825fn 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/// #1814: a test suite's composed symbol tables, closed over the types its
3834/// imported declarations reach. `hidden` names each type the closure added,
3835/// with its owning commons: resolvable in the view, never nameable by the
3836/// suite (`resolver::check_hidden_type_names_in_suite`), and imported by the
3837/// test scaffold from that commons.
3838#[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    /// Review of #1814: each reached name the view already binds to another
3845    /// declaration (`project_model::close_view_types`).
3846    pub conflicts: Vec<crate::project_model::ViewConflict>,
3847    /// #1883, slice 3 ([DECISION F]): the assets a case may name — those the
3848    /// target's Worker ships: the target's own and its `uses`d commons'
3849    /// (content-hashed and fixed-path), plus the content-hashed assets of the
3850    /// contexts it consumes. An asset name types as `Asset`;
3851    /// `svc.GET(asset)` fetches the file.
3852    pub assets: HashMap<String, bynk_syntax::ast::AssetDecl>,
3853    /// #2387, slice 2: the embeds a unit-tier case may name — the target's
3854    /// own and its `uses`d commons', `String` constants of the target's
3855    /// module. Empty for a `system` view: an embed is code, not something
3856    /// served by a Worker.
3857    pub embeds: HashMap<String, bynk_syntax::ast::EmbedDecl>,
3858}
3859
3860impl SuiteView {
3861    /// #2387, slice 2: add the embeds `unit` sees — its own and those of the
3862    /// commons it `uses`, one level, as for functions — first name winning.
3863    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    /// #1883, slice 3: add the assets `unit` sees — its own and those of the
3885    /// commons it `uses`, one level, as for functions — first name winning.
3886    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    /// [`Self::merge_assets`], keeping only the declarations `keep` admits.
3896    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    /// What the closure found, without the tables.
3919    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    /// Merge `t`'s types and methods (and its fns, when `with_fns`) into the
3927    /// view, first declaration of a name winning; `with_statics` brings static
3928    /// methods too.
3929    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    /// #1814: close the view over what the declarations of `imported` reach
3958    /// (`project_model::close_view_types`), recording what it adds.
3959    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
3978/// `owning_unit`'s privileged view: its types, fns, methods, plus those of
3979/// every commons it `uses`, plus the types and instance methods of every
3980/// consumed context. #1814: closed over the types those imported declarations
3981/// reach, as the unit's own table is, so a case is checked against the same
3982/// types the target is.
3983pub 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    // Consumed-context types come in too: every one, not only the exported
4004    // ones, since the view is privileged. Review of #1814: so a reached name
4005    // can bind here to an unexported type the target's own table never
4006    // binds; `check_test_bodies` reports that.
4007    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    // Review of #1923: the content-hashed assets of each consumed context
4016    // too — the target's Worker ships them (`assets::deployed_assets`), so a
4017    // fragment a consumed service returns resolves. Its fixed paths stay
4018    // with its own Worker, so they are not nameable here.
4019    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    // #1925: a function or asset the target hides behind its own declaration
4023    // of another kind is out of scope here too, as in the target's module
4024    // (`project_model::shadowed_imports`): a case names the target's own.
4025    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
4036/// #1814: a `system` case's harness view: types, fns and methods from the
4037/// suite's `uses` commons (for constructing arguments), plus each
4038/// participant's types and methods (so return types rebrand and variant
4039/// patterns resolve), closed over the types those declarations reach.
4040pub 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    // #1883, slice 3: the assets the target's Worker ships, mirroring
4057    // `assets::deployed_assets` — the target's own and its `uses`d commons'
4058    // (hashed and fixed), plus each other participant's content-hashed ones
4059    // (a consumed context, whose fragment may carry its `.url`). Review of
4060    // #1922: a participant's fixed path, or an asset of the suite's own
4061    // `uses` commons, is not served by the target's Worker, so it is not
4062    // nameable either (an ordinary unknown name rather than a `NotFound`).
4063    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        // #1925: an asset the target hides behind its own declaration of
4069        // another kind is not nameable, as in `privileged_view`. Its file
4070        // still ships: the commons that declares it may use it.
4071        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
4081/// Build a [`resolver::ResolvedCommons`] backed by `owning_unit`'s
4082/// [`privileged_view`]. The same shape used by the production pipeline.
4083/// Returns the [`ResolvedCommons`] plus what the view's closure found (#1814):
4084/// its hidden types and its conflicts.
4085pub 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        // "Privileged" test/stub-body resolved — deliberately relaxed, not a
4142        // real context emission subject to the rebrand — so events stay
4143        // empty rather than reading `local`'s.
4144        &no_local_events,
4145        cross_context,
4146        HashMap::new(),
4147        false,
4148        HashSet::new(),
4149    )
4150    // #1883, slice 3: the target's assets, nameable and fetchable in a case.
4151    .with_assets(assets)
4152    .with_embeds(embeds)
4153    // #1925: the imports the target hides, so a case naming one reports the
4154    // conflict as the target's own code would.
4155    .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    /// The body of `service svc`'s single handler in a parsed context.
4171    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    /// #1740 review: the walk is in source order (it used to run bottom-up), so
4197    /// "the first cross-context call" and the order errors are reported in
4198    /// both read top to bottom.
4199    #[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    /// `blocks_deep` reaches a block nested in an `if` branch, so a `let` there
4213    /// (an agent binding, or a name that shadows a service) is seen.
4214    #[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    /// The context of negative fixture `388_credential_needs_system`.
4230    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    /// #2135: a `Signature`-secured context, for the bad-signature principals.
4265    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    /// #2155: a `Signature` actor with a timestamp but no tolerance, a sum
4291    /// with a `Signature` member, and a sum with none.
4292    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    /// A sum with `Visitor` resolves a bad or missing credential as
4357    /// `Visitor`, so its seam never answers `401`.
4358    #[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        // #1849: only the top-level statements used to be searched.
4415        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/// #1934: a case's service call is typed by what its handler yields
4423/// (re-opening ADR 0203), and a `system`-tier call that reaches past the
4424/// handler's types by what the wire yields.
4425#[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    /// The case of #1931 and #1934: a method on a value of the handler's
4473    /// result type checks, and a wrong one is reported against that type.
4474    #[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    /// A payload of fixed type binds it, as before; the arms are checked by
4496    /// the ordinary rules.
4497    #[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    /// An `on call` handler's return type is what the case binds.
4511    #[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    /// cron and queue handlers are typed by their return types too, so a
4519    /// queue verdict is matchable.
4520    #[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    /// The context of `385_system_wire_rejection`, cut down.
4533    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    /// A typed `system`-tier call decodes the handler's `HttpResult`.
4558    #[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    /// `Wire`, `by Nobody` and a wrong method observe the boundary, so they
4567    /// yield an `HttpOutcome`: its kinds are checked, and `Handled` carries
4568    /// the handler's `HttpResult`.
4569    #[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        // Both at once (the raw no-auth driver) is still an `HttpOutcome`.
4583        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    /// `(publisher, event)` → its subscribers, as
4603    /// `symbols::discover_event_subscribers` builds it.
4604    type Subscribers = BTreeMap<(String, String), Vec<(String, String)>>;
4605
4606    /// `ops.hub` consumes `ops.store` and the `bynk` adapter, and publishes
4607    /// `Ping`. `ops.audit` subscribes to it and consumes `ops.archive`, a
4608    /// context outside `ops.hub`'s closure. `ops.store` publishes `Saved`, to
4609    /// which `ops.mirror` subscribes, and `ops.audit` publishes `Noted`, to
4610    /// which `ops.digest` subscribes.
4611    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    /// A case reaches the target's `consumes` closure: contexts only, the
4655    /// target first.
4656    #[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    /// #1959: the harness also stands up the target's subscriber and its own
4666    /// closure (`ops.archive`), a non-target participant's subscriber
4667    /// (`ops.mirror`), and a subscriber's subscriber (`ops.digest`).
4668    #[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    /// With no subscribers the two sets agree.
4685    #[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/// #2018: the `is`-pattern comparison the history restate check uses. It is
4696/// conservative: only literals and payload-free variants (same name, same
4697/// optional qualifier) are equal; anything else compares unequal.
4698#[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/// #2052: the `svc.event(e)` address's checks that the fixtures don't pin
4796/// (a note's text) or that need a project shape of their own.
4797#[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        // `shop.archive` declares an event `Note`, but the target doesn't
4843        // consume it; the target's own `Note` is a record, not an event.
4844        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}