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;
71use bynk_syntax::ast::*;
72use bynk_syntax::error::CompileError;
73use bynk_syntax::span::Span;
74
75/// v0.118: a capability seam with one or more `stub` overrides applied
76/// (testing track slice 6). Groups every `stub Cap.method(…)` clause — both
77/// suite-scoped and case-scoped — targeting the same capability `cap`. The
78/// resolved [`CapabilityDecl`] supplies each overridden method's parameter names
79/// and return type for stub emission.
80#[derive(Debug, Clone)]
81pub struct ResolvedStub {
82    /// The capability being overridden (a declared/consumed seam of the target).
83    pub cap: String,
84    /// The capability declaration, for op parameter names and return types.
85    pub cap_decl: CapabilityDecl,
86    /// The `stub` clauses for this capability, in match order (case-scoped
87    /// first so they take precedence over suite-scoped in the emitted if-chain).
88    pub clauses: Vec<StubClause>,
89    /// #291: parallel to `clauses` — the name of the case each clause is scoped
90    /// to, or `None` for a suite-scoped clause. A case-scoped clause applies only
91    /// while its own case runs.
92    pub clause_cases: Vec<Option<String>>,
93    /// The test file declaring the first clause — the recording context for
94    /// edges in its value expressions (v0.25).
95    ///
96    /// ADR 0198/0201: a *recording context* is an index key, so this is the
97    /// file's **identity** (project-relative), not its `include`-root-relative
98    /// unit path. Everything the index keys must name a file the round
99    /// analysed.
100    pub identity_path: PathBuf,
101}
102
103/// P5.4 (`design/tracks/semantics-in-the-checker.md` §6): the checking half
104/// of `test <target>` suite processing — target resolution, duplicate-case-
105/// name detection, `stub`-clause resolution, and case/property body
106/// type-checking. Formerly Phases 2-4 of `bynk-emit`'s own `process_tests`;
107/// Phase 5 (TypeScript emission) stays in
108/// `bynk-emit::project::tests_emit::process_tests`, which calls this
109/// function for its checking half and then emits only for the targets this
110/// returns — every target this function resolves, has no duplicate case
111/// names, and whose bodies type-check clean is exactly "ready for
112/// emission". `bynk_check::analysis::analyse_project` calls this too and
113/// discards the returned map — it never emits, so only the diagnostic/
114/// `RefSink` side effects matter there. Closes category 7 of
115/// `bynk-check/src/analysis.rs`'s own residual-gap accounting, alongside
116/// [`phase_integration_bodies`].
117#[allow(clippy::too_many_arguments)]
118pub fn phase_test_bodies(
119    test_groups: &BTreeMap<String, Vec<usize>>,
120    parsed: &[ParsedFile],
121    kinds: &BTreeMap<String, UnitKind>,
122    unit_tables: &HashMap<String, UnitTable>,
123    exports_visibility: &HashMap<String, HashMap<String, Visibility>>,
124    unit_consumes: &HashMap<String, Vec<String>>,
125    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
126    unit_uses: &HashMap<String, Vec<String>>,
127    errors: &mut Vec<CompileError>,
128    refs: &mut RefSink,
129    tys: &Arc<Types>,
130) -> HashMap<String, HashMap<String, ResolvedStub>> {
131    let mut ready: HashMap<String, HashMap<String, ResolvedStub>> = HashMap::new();
132
133    let mut sorted_targets: Vec<&String> = test_groups.keys().collect();
134    sorted_targets.sort();
135
136    for target_name in sorted_targets {
137        let indices = test_groups.get(target_name).unwrap();
138        // -- Phase 2: target resolution --
139        let target_kind = match kinds.get(target_name) {
140            Some(k) => *k,
141            None => {
142                let span = first_test_target_span(indices, parsed);
143                errors.push(
144                    CompileError::new(
145                        "bynk.suite.unknown_target",
146                        span,
147                        format!(
148                            "test target `{target_name}` is not a declared commons or context in this project",
149                        ),
150                    )
151                    .with_note(
152                        "the target of a `test` declaration must be a commons or context declared elsewhere in the project",
153                    ),
154                );
155                continue;
156            }
157        };
158
159        // -- Phase 2: duplicate test case names --
160        let mut seen_cases: HashMap<String, Span> = HashMap::new();
161        let mut had_dup = false;
162        for &i in indices {
163            if let Some(t) = parsed[i].test() {
164                for case in &t.cases {
165                    if let Some(prev) = seen_cases.get(&case.name) {
166                        had_dup = true;
167                        errors.push(
168                            CompileError::new(
169                                "bynk.suite.duplicate_case_name",
170                                case.name_span,
171                                format!(
172                                    "test case `\"{}\"` is declared more than once in tests targeting `{target_name}`",
173                                    case.name
174                                ),
175                            )
176                            .with_label(*prev, "previously declared here"),
177                        );
178                    } else {
179                        seen_cases.insert(case.name.clone(), case.name_span);
180                    }
181                }
182            }
183        }
184
185        // -- Phase 3: resolve `stub` clauses (v0.118, testing track slice 6).
186        // Both suite-scoped and case-scoped `stub` fold into one per-seam
187        // override map. Case-scoped clauses are collected first so they take
188        // precedence over suite-scoped ones in the emitted first-match if-chain
189        // (the case > suite > default order; a first-cut global merge — a
190        // case-scoped clause is not yet re-scoped to its own case). Runs
191        // unconditionally, even when `had_dup` — its own diagnostics still
192        // fire, matching `process_tests`'s original Phase 2/3 ordering.
193        let target_stubs = resolve_stubs(
194            target_name,
195            target_kind,
196            indices,
197            parsed,
198            unit_tables,
199            unit_consumes,
200            errors,
201        );
202
203        if had_dup {
204            // Skip body/type-checking for this target; we have name conflicts.
205            continue;
206        }
207
208        // -- Phase 4: type-check bodies. --
209        // (We build a resolved view targeting either commons or context;
210        // mock bodies are type-checked with the mocked entity's privileges.)
211        let bodies_errs = check_test_bodies(
212            target_name,
213            target_kind,
214            indices,
215            parsed,
216            &target_stubs,
217            unit_tables,
218            exports_visibility,
219            unit_consumes,
220            unit_consumes_aliases,
221            unit_uses,
222            refs,
223            tys,
224        );
225        let bodies_failed = !bodies_errs.is_empty();
226        errors.extend(bodies_errs);
227
228        if bodies_failed {
229            continue;
230        }
231
232        ready.insert(target_name.clone(), target_stubs);
233    }
234
235    ready
236}
237
238/// v0.118: resolve every `stub Cap.method(…)` clause targeting a unit into a
239/// per-capability [`ResolvedStub`] (testing track slice 6, ADR 0154). Both
240/// suite-scoped and case-scoped clauses fold in; a capability that is neither a
241/// declared seam of the target nor reachable through a consumed context is
242/// `bynk.stub.not_a_seam`, an unknown method is `bynk.stub.unknown_op`,
243/// and an empty `returns each []` is `bynk.stub.bad_sequence`.
244fn resolve_stubs(
245    target_name: &str,
246    target_kind: UnitKind,
247    indices: &[usize],
248    parsed: &[ParsedFile],
249    unit_tables: &HashMap<String, UnitTable>,
250    unit_consumes: &HashMap<String, Vec<String>>,
251    errors: &mut Vec<CompileError>,
252) -> HashMap<String, ResolvedStub> {
253    let target_table = unit_tables.get(target_name);
254    let target_consumed = unit_consumes.get(target_name).cloned().unwrap_or_default();
255
256    // Collect clauses tagged with the declaring file. Case-scoped first so they
257    // precede suite-scoped clauses in each capability's match order.
258    let mut collected: Vec<(StubClause, Option<String>, PathBuf)> = Vec::new();
259    for &i in indices {
260        let Some(t) = parsed[i].test() else { continue };
261        for case in &t.cases {
262            for pc in &case.stubs {
263                collected.push((
264                    pc.clone(),
265                    Some(case.name.clone()),
266                    parsed[i].identity_path(),
267                ));
268            }
269        }
270    }
271    for &i in indices {
272        let Some(t) = parsed[i].test() else { continue };
273        for pc in &t.stubs {
274            collected.push((pc.clone(), None, parsed[i].identity_path()));
275        }
276    }
277
278    // Resolve a capability name to its declaration: a capability the target
279    // declares (or has flattened in via `consumes U { Cap }`), else a capability
280    // of a consumed context.
281    let resolve_cap = |name: &str| -> Option<CapabilityDecl> {
282        target_table
283            .and_then(|t| t.capabilities.get(name).cloned())
284            .or_else(|| {
285                target_consumed.iter().find_map(|q| {
286                    unit_tables
287                        .get(q)
288                        .and_then(|t| t.capabilities.get(name).cloned())
289                })
290            })
291    };
292
293    let mut out: HashMap<String, ResolvedStub> = HashMap::new();
294    for (pc, case, identity_path) in collected {
295        let cap_name = pc.capability.name.clone();
296        let Some(cap_decl) = resolve_cap(&cap_name) else {
297            // Commons have no seams at all; contexts may still name a
298            // non-existent capability. Either way it is not a seam.
299            let note = if target_kind == UnitKind::Commons {
300                "commons have no capability seams — `stub` overrides a capability the target context declares or consumes"
301            } else {
302                "a `stub` clause names a capability the target context declares or reaches through a consumed context"
303            };
304            errors.push(
305                CompileError::new(
306                    "bynk.stub.not_a_seam",
307                    pc.capability.span,
308                    format!("`{cap_name}` is not a capability seam of `{target_name}`",),
309                )
310                .with_note(note),
311            );
312            continue;
313        };
314        let Some(op_decl) = cap_decl.ops.iter().find(|o| o.name.name == pc.method.name) else {
315            errors.push(CompileError::new(
316                "bynk.stub.unknown_op",
317                pc.method.span,
318                format!(
319                    "`{}` is not an operation of capability `{cap_name}`",
320                    pc.method.name
321                ),
322            ));
323            continue;
324        };
325        // #926 (Decision F): a generic capability operation cannot be stubbed
326        // — `__Stub_Cap`'s per-op method body has no way to construct a
327        // value of the op's unconstrained `T`. Deferred rather than
328        // supported: the stub class carries no `implements` clause (its
329        // members are duck-typed through an untyped `deps` seam), so
330        // stubbing another, non-generic op of the same capability keeps
331        // type-checking.
332        if !op_decl.type_params.is_empty() {
333            errors.push(
334                CompileError::new(
335                    "bynk.stub.generic_op",
336                    pc.method.span,
337                    format!(
338                        "`{cap_name}.{}` declares its own type parameter — a generic capability operation cannot be stubbed at v1",
339                        pc.method.name
340                    ),
341                )
342                .with_note(
343                    "test through the capability's real (external) provider instead, or restructure the test to avoid stubbing this operation",
344                ),
345            );
346            continue;
347        }
348        if let StubRhs::ReturnsEach(outcomes, span) = &pc.rhs
349            && outcomes.is_empty()
350        {
351            errors.push(CompileError::new(
352                "bynk.stub.bad_sequence",
353                *span,
354                format!(
355                    "`stub {cap_name}.{} returns each []` has no outcomes — a sequence needs at least one",
356                    pc.method.name
357                ),
358            ));
359            continue;
360        }
361        let entry = out.entry(cap_name.clone()).or_insert_with(|| ResolvedStub {
362            cap: cap_name.clone(),
363            cap_decl: cap_decl.clone(),
364            clauses: Vec::new(),
365            clause_cases: Vec::new(),
366            identity_path: identity_path.clone(),
367        });
368        entry.clauses.push(pc);
369        entry.clause_cases.push(case);
370    }
371    out
372}
373
374/// v0.118: infer a `system`-tier suite's wired participants — the target's
375/// transitive `consumes` closure (testing track slice 6). A BFS from the target
376/// following `consumes` edges; the returned list starts with the target and
377/// includes every context reachable through it (deterministic breadth order).
378pub fn infer_participants(
379    target: &str,
380    unit_consumes: &HashMap<String, Vec<String>>,
381) -> Vec<String> {
382    let mut seen: HashSet<String> = HashSet::new();
383    let mut order: Vec<String> = Vec::new();
384    let mut queue: Vec<String> = vec![target.to_string()];
385    seen.insert(target.to_string());
386    let mut head = 0;
387    while head < queue.len() {
388        let node = queue[head].clone();
389        head += 1;
390        order.push(node.clone());
391        if let Some(deps) = unit_consumes.get(&node) {
392            for d in deps {
393                if seen.insert(d.clone()) {
394                    queue.push(d.clone());
395                }
396            }
397        }
398    }
399    order
400}
401
402/// P5.4 (`design/tracks/semantics-in-the-checker.md` §6): the checking half
403/// of `test integration "name"` suite processing — participant inference,
404/// the `system`-needs-a-serialisation-edge gate, duplicate-case-name
405/// detection, the harness-root cross-context view, and per-case body
406/// type-checking (including the `Wire`/`by Nobody` tier gates). Formerly the
407/// pre-emission logic of `bynk-emit`'s own `process_integration_tests`;
408/// emission stays in `bynk-emit::project::tests_emit::process_integration_tests`,
409/// which calls this function for its checking half and then emits only for
410/// the groups this returns. Unlike [`phase_test_bodies`]'s `ResolvedStub`
411/// map, the only thing worth handing back here is the harness's
412/// [`resolver::CrossContextInfo`] — it's built from clone-heavy maps
413/// (`harness_consumes`/`harness_uses`), so recomputing it a second time on
414/// the emit side would be wasted work. `participants`/`uses_targets`/
415/// `case_inputs` are cheap and pure (a BFS, a linear scan), so the emit-side
416/// loop recomputes those itself from `parsed`/`unit_consumes`, using the
417/// now-relocated [`infer_participants`]. `bynk_check::analysis::analyse_project`
418/// calls this too and discards the returned map — it never emits. Closes
419/// category 7 of `bynk-check/src/analysis.rs`'s own residual-gap accounting,
420/// alongside [`phase_test_bodies`].
421#[allow(clippy::too_many_arguments)]
422pub fn phase_integration_bodies(
423    integration_groups: &BTreeMap<String, Vec<usize>>,
424    parsed: &[ParsedFile],
425    unit_tables: &HashMap<String, UnitTable>,
426    unit_consumes: &HashMap<String, Vec<String>>,
427    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
428    unit_uses: &HashMap<String, Vec<String>>,
429    errors: &mut Vec<CompileError>,
430    refs: &mut RefSink,
431    tys: &Arc<Types>,
432) -> HashMap<String, resolver::CrossContextInfo> {
433    let mut ready: HashMap<String, resolver::CrossContextInfo> = HashMap::new();
434
435    let mut sorted: Vec<&String> = integration_groups.keys().collect();
436    sorted.sort();
437
438    for group_name in sorted {
439        let indices = integration_groups.get(group_name).unwrap();
440        let first = indices[0];
441        let Some(decl) = parsed[first].integration() else {
442            continue;
443        };
444        // v0.118: there is no `suite` string any more — the wired suite is named
445        // for its target context. The participant set is INFERRED from the
446        // target's transitive `consumes` closure (no `wires` list).
447        let suite_target = decl.target.joined();
448        let participants = infer_participants(&suite_target, unit_consumes);
449
450        let mut bad = false;
451
452        // v0.118 / testing-the-boundary Slice B: a `system` suite needs a real
453        // **serialisation edge** — not merely ≥ 2 participants. The original rule
454        // (`participants.len() < 2`) was a proxy for "nothing to serialise
455        // across", exact only when the sole edge was cross-context. A single
456        // context that exposes an `http` service has a real edge (the public
457        // boundary: deserialise → handler → serialise), so it qualifies.
458        //
459        // Only `http` is admitted here, because only http-at-system is *wired*
460        // (`emit_system_http_support` drives `worker.fetch`). A `queue` service
461        // does serialise its message, but driving a queue over a real wire at
462        // `system` is not built this slice — admitting it would let a queue-only
463        // target compile as `system` while `q.message(...)` silently fell through
464        // to the unit-tier direct call (no wire). `cron` never qualifies —
465        // `scheduled` serialises nothing. Queue-at-system is a noted follow-on.
466        let has_serialisation_edge = unit_tables.get(&suite_target).is_some_and(|t| {
467            t.services
468                .values()
469                .any(|s| matches!(s.protocol, bynk_syntax::ast::ServiceProtocol::Http))
470        });
471        if participants.len() < 2 && !has_serialisation_edge {
472            errors.push(
473                CompileError::new(
474                    "bynk.tier.system_needs_wire",
475                    decl.target.span,
476                    format!(
477                        "`system`-tier suite for `{suite_target}` has no serialisation edge — the target consumes no other context and exposes no `http` service",
478                    ),
479                )
480                .with_note(
481                    "a `system` case crosses a real serialise → JSON → deserialise boundary; this target has none to cross, so `unit` already covers it",
482                ),
483            );
484            bad = true;
485        }
486
487        // -- Duplicate case names within the suite. --
488        let mut seen_cases: HashMap<String, Span> = HashMap::new();
489        for &i in indices {
490            let Some(d) = parsed[i].integration() else {
491                continue;
492            };
493            for case in &d.cases {
494                if let Some(prev) = seen_cases.get(&case.name) {
495                    errors.push(
496                        CompileError::new(
497                            "bynk.suite.duplicate_case_name",
498                            case.name_span,
499                            format!(
500                                "test case `\"{}\"` is declared more than once in tests targeting `{suite_target}`",
501                                case.name
502                            ),
503                        )
504                        .with_label(*prev, "previously declared here"),
505                    );
506                    bad = true;
507                } else {
508                    seen_cases.insert(case.name.clone(), case.name_span);
509                }
510            }
511        }
512
513        // -- #1738: a `system` case can't share a suite with lower-tier ones. --
514        // Reported without marking the suite `bad`: the per-case tier gates
515        // below (`Wire`, `by Nobody`) still check its bodies, and any error
516        // already stops emission.
517        // A suite with any `system` case is emitted, whole, as the wired module
518        // that drives deployed Workers, where a service is addressed by its
519        // context path (`shop.orders.place(…)`), not `place.call(…)`. A lower-
520        // tier case there has no in-process target to call (it lowered to
521        // `/* unknown */`), so the tiers are kept to separate suites.
522        for &i in indices {
523            let Some(d) = parsed[i].integration() else {
524                continue;
525            };
526            let suite_is_system = d.tier == Some(bynk_syntax::ast::TestTier::System);
527            let has_lower = d
528                .cases
529                .iter()
530                .any(|c| case_effective_tier(c, d) != bynk_syntax::ast::TestTier::System);
531            if !has_lower {
532                continue;
533            }
534            for case in &d.cases {
535                let tier = case_effective_tier(case, d);
536                // In a `system` suite, flag each case that steps down; in any
537                // other suite, flag each case that steps up to `system`.
538                let flagged = if suite_is_system {
539                    tier != bynk_syntax::ast::TestTier::System
540                } else {
541                    case.tier == Some(bynk_syntax::ast::TestTier::System)
542                };
543                if !flagged {
544                    continue;
545                }
546                let (message, advice) = if suite_is_system {
547                    (
548                        format!(
549                            "case `\"{}\"` is `{}`-tier, but its suite is `as system`",
550                            case.name,
551                            tier.as_str()
552                        ),
553                        format!(
554                            "move this case into a separate `suite {suite_target}` without `as system`"
555                        ),
556                    )
557                } else {
558                    (
559                        format!(
560                            "case `\"{}\"` is `as system`, but other cases in its suite run below `system`",
561                            case.name
562                        ),
563                        format!(
564                            "move this case into its own `suite {suite_target} as system {{ … }}`, addressing services by context path (`{suite_target}.<service>(…)`)"
565                        ),
566                    )
567                };
568                errors.push(
569                    CompileError::new("bynk.tier.mixed_system_suite", case.name_span, message)
570                        .with_note(format!(
571                            "a `system` case runs against deployed Workers, so its suite holds `system` cases only; {advice}"
572                        )),
573                );
574            }
575        }
576
577        if bad {
578            continue;
579        }
580
581        // -- Build the harness-root cross-context view (consumes all). --
582        let harness_name = group_name.clone();
583        let mut uses_targets: Vec<String> = Vec::new();
584        for &i in indices {
585            if let Some(d) = parsed[i].integration() {
586                for u in &d.uses {
587                    let q = u.target.joined();
588                    if !uses_targets.contains(&q) {
589                        uses_targets.push(q);
590                    }
591                }
592            }
593        }
594        let mut harness_consumes = unit_consumes.clone();
595        harness_consumes.insert(harness_name.clone(), participants.clone());
596        let mut harness_uses = unit_uses.clone();
597        harness_uses.insert(harness_name.clone(), uses_targets.clone());
598        let cross_context = build_cross_context_info(
599            &harness_name,
600            &harness_consumes,
601            unit_consumes_aliases,
602            &harness_uses,
603            unit_tables,
604        );
605
606        // -- Type-check each case body. --
607        let mut body_errs: Vec<CompileError> = Vec::new();
608        // v0.25: the harness root is a synthetic namespace — declare its
609        // resolution order (uses first, then participants) for assembly.
610        let mut harness_resolution = uses_targets.clone();
611        harness_resolution.extend(participants.iter().cloned());
612        refs.declare_namespace(&harness_name, harness_resolution);
613        for &i in indices {
614            let Some(d) = parsed[i].integration() else {
615                continue;
616            };
617            refs.enter_file(
618                &parsed[i].identity_path(),
619                &harness_name,
620                parsed[i].is_synthetic(),
621            );
622            for case in &d.cases {
623                check_integration_case_body(
624                    &participants,
625                    &uses_targets,
626                    case,
627                    &cross_context,
628                    unit_tables,
629                    &mut body_errs,
630                    refs,
631                    tys,
632                );
633                check_faults_tier(case, case_effective_tier(case, d), &mut body_errs);
634                // Slice C: `Wire(…)` is a `system`-only raw argument (it drives the
635                // real wire); in a non-`system` case it has no wire to be raw
636                // about, so lowering it would silently pass raw text to a direct
637                // in-process handler call. Reject it at the tier where it is known.
638                if !matches!(
639                    case_effective_tier(case, d),
640                    bynk_syntax::ast::TestTier::System
641                ) && block_uses_wire(&case.body)
642                {
643                    body_errs.push(CompileError::new(
644                        "bynk.test.wire_needs_system",
645                        case.name_span,
646                        format!(
647                            "case `\"{}\"` uses `Wire(...)` but is not a `system`-tier case",
648                            case.name
649                        ),
650                    ).with_note(
651                        "`Wire` hands raw, pre-validation input to the real boundary; promote the case with `as system`, or pass a typed argument",
652                    ));
653                }
654                // #706: `by Nobody` presents no credential to the real auth seam
655                // (the 401 path), which exists only at `system`; at a lower tier
656                // the handler just runs with no identity, silently not a 401.
657                if !matches!(
658                    case_effective_tier(case, d),
659                    bynk_syntax::ast::TestTier::System
660                ) && block_uses_nobody(&case.body)
661                {
662                    body_errs.push(CompileError::new(
663                        "bynk.test.credential_needs_system",
664                        case.name_span,
665                        format!(
666                            "case `\"{}\"` drives `by Nobody` but is not a `system`-tier case",
667                            case.name
668                        ),
669                    ).with_note(
670                        "`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>)`",
671                    ));
672                }
673            }
674        }
675        let bodies_failed = !body_errs.is_empty();
676        errors.extend(body_errs);
677        if bodies_failed {
678            continue;
679        }
680
681        ready.insert(group_name.clone(), cross_context);
682    }
683
684    ready
685}
686
687/// Type-check one integration test case body. The body lives in a synthetic
688/// harness root that consumes every participant; entry calls
689/// (`ctx.service(args)`) are therefore ordinary cross-context calls. The body
690/// has type `Effect[Result[(), ExpectationError]]` (modelled as
691/// `Effect[Result[(), ValidationError]]`, as in unit tests).
692#[allow(clippy::too_many_arguments)]
693fn check_integration_case_body(
694    participants: &[String],
695    uses_targets: &[String],
696    case: &Case,
697    cross_context: &resolver::CrossContextInfo,
698    unit_tables: &HashMap<String, UnitTable>,
699    errors: &mut Vec<CompileError>,
700    refs: &mut RefSink,
701    tys: &Arc<Types>,
702) {
703    // Names in scope: types/fns/methods from `uses` commons (for constructing
704    // arguments) plus each participant's types/methods (so return types rebrand
705    // and variant patterns resolve).
706    let mut types: HashMap<String, Arc<TypeDecl>> = HashMap::new();
707    let mut fns: HashMap<String, Arc<FnDecl>> = HashMap::new();
708    let mut methods: HashMap<String, ResolverMethodTable> = HashMap::new();
709    let mut merge = |src: Option<&UnitTable>, with_fns: bool| {
710        let Some(t) = src else { return };
711        for (n, d) in &t.types {
712            types.entry(n.clone()).or_insert_with(|| d.clone());
713        }
714        if with_fns {
715            for (n, f) in &t.fns {
716                fns.entry(n.clone()).or_insert_with(|| f.clone());
717            }
718        }
719        for (n, mt) in &t.methods {
720            let entry = methods.entry(n.clone()).or_default();
721            for (m, decl) in &mt.instance {
722                entry
723                    .instance
724                    .entry(m.clone())
725                    .or_insert_with(|| decl.clone());
726            }
727            for (m, decl) in &mt.statics {
728                entry
729                    .statics
730                    .entry(m.clone())
731                    .or_insert_with(|| decl.clone());
732            }
733        }
734    };
735    for u in uses_targets {
736        merge(unit_tables.get(u), true);
737    }
738    for p in participants {
739        merge(unit_tables.get(p), false);
740    }
741
742    let synthetic_commons = Commons {
743        name: QualifiedName {
744            parts: vec![Ident {
745                name: "integration".to_string(),
746                span: Span::default(),
747            }],
748            span: Span::default(),
749        },
750        items: Vec::new(),
751        uses: Vec::new(),
752        documentation: None,
753        form: CommonsForm::Brace,
754        span: Span::default(),
755        trivia: Trivia::default(),
756        trailing_comments: Vec::new(),
757    };
758    // `synthetic_commons` declares nothing of its own (`items: Vec::new()`
759    // above) — every entry in `types`/`fns`/`methods` was merged in from a
760    // `uses`/participant unit, so an empty local table (no local types, no
761    // local events) is the correct answer here, not a stand-in for one.
762    let no_local_types = HashMap::new();
763    let no_local_events = HashMap::new();
764    let resolved = ResolvedCommons::new(
765        synthetic_commons,
766        types,
767        &no_local_types,
768        fns,
769        methods,
770        HashMap::new(),
771        &no_local_events,
772        cross_context.clone(),
773        HashMap::new(),
774        // Test-scaffold body, not a real context emission — never rebranded.
775        false,
776        HashSet::new(),
777    );
778
779    let unit_span = case.span;
780    let synthetic_return = TypeRef::Effect(
781        Box::new(TypeRef::Result(
782            Box::new(TypeRef::Unit(unit_span)),
783            Box::new(TypeRef::ValidationError(unit_span)),
784            unit_span,
785        )),
786        unit_span,
787    );
788    let return_ty = checker::resolve_type_ref(&synthetic_return, &resolved.types, tys).unwrap();
789    let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
790    let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
791    // Test bodies record no hints (out of v0.27 scope) — a throwaway sink.
792    let mut no_hints = HintSink::new();
793    let mut no_locals = LocalsSink::new();
794    // Test bodies record no capability requirements either — muted sink.
795    let mut no_requirements = RequirementSink::new();
796    let _ = checker::check_body(
797        &resolved,
798        &case.body,
799        return_ty,
800        case.span,
801        HashMap::new(),
802        checker::CapabilityCtx::default(),
803        // Slice B: a `system` case addresses the target's own service (`api.POST`)
804        // and names a principal (`by User(...)`), so the checker needs the
805        // target's services and actors — the same resolution the unit tier does.
806        target_test_services(participants.first().and_then(|t| unit_tables.get(t))),
807        target_test_actors(participants.first().and_then(|t| unit_tables.get(t))),
808        None,
809        checker::CheckSinks {
810            tys,
811            expr_types: &mut expr_types,
812            errors,
813            refs,
814            hints: &mut no_hints,
815            locals: &mut no_locals,
816            requirements: &mut no_requirements,
817            callees: &mut callees,
818        },
819    );
820}
821
822fn first_test_target_span(indices: &[usize], parsed: &[ParsedFile]) -> Span {
823    indices
824        .first()
825        .and_then(|&i| parsed[i].test().map(|t| t.target.span))
826        .unwrap_or_default()
827}
828
829/// Type-check test/property bodies for a target and validate `stub` RHS
830/// value types (v0.118). Bodies use the target's privileged view; a `stub`
831/// value whose type disagrees with the overridden op's return is
832/// `bynk.stub.rhs_type`.
833#[allow(clippy::too_many_arguments)]
834fn check_test_bodies(
835    target_name: &str,
836    target_kind: UnitKind,
837    indices: &[usize],
838    parsed: &[ParsedFile],
839    stubs: &HashMap<String, ResolvedStub>,
840    unit_tables: &HashMap<String, UnitTable>,
841    exports_visibility: &HashMap<String, HashMap<String, Visibility>>,
842    unit_consumes: &HashMap<String, Vec<String>>,
843    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
844    unit_uses: &HashMap<String, Vec<String>>,
845    refs: &mut RefSink,
846    tys: &Arc<Types>,
847) -> Vec<CompileError> {
848    let mut errors = Vec::new();
849    let _ = exports_visibility;
850
851    // v0.118: validate each `stub` RHS value's type against the overridden
852    // op's declared return type, in the target's privileged view. A best-effort
853    // check: the value expression is type-checked as if it were the op body's
854    // tail; any resulting error surfaces as `bynk.stub.rhs_type`.
855    if !stubs.is_empty()
856        && let Some((resolved, _)) = build_privileged_resolved(
857            target_name,
858            unit_tables,
859            unit_uses,
860            unit_consumes,
861            unit_consumes_aliases,
862        )
863    {
864        for rp in stubs.values() {
865            refs.enter_file(&rp.identity_path, target_name, false);
866            for clause in &rp.clauses {
867                let Some(op) = rp
868                    .cap_decl
869                    .ops
870                    .iter()
871                    .find(|o| o.name.name == clause.method.name)
872                else {
873                    continue;
874                };
875                let check_value = |e: &Expr, errors: &mut Vec<CompileError>| {
876                    if !stub_value_typechecks(e, op, &resolved, tys) {
877                        errors.push(CompileError::new(
878                            "bynk.stub.rhs_type",
879                            e.span,
880                            format!(
881                                "the value provided for `{}.{}` does not match the operation's declared return type `{}`",
882                                rp.cap,
883                                op.name.name,
884                                ts_type_ref_display(&op.return_type),
885                            ),
886                        ));
887                    }
888                };
889                match &clause.rhs {
890                    StubRhs::Returns(e) => check_value(e, &mut errors),
891                    StubRhs::ReturnsEach(outcomes, _) => {
892                        for o in outcomes {
893                            if let SeqOutcome::Value(e) = o {
894                                check_value(e, &mut errors);
895                            }
896                        }
897                    }
898                    StubRhs::Fails(_) => {}
899                }
900            }
901        }
902    }
903
904    // #1737: which target services reach another context, for the tier gate
905    // in the loop below. Computed once per target.
906    let crossing = CrossingServices::of(
907        target_name,
908        unit_tables,
909        unit_consumes,
910        unit_consumes_aliases,
911    );
912
913    // Type-check test case bodies — they live in the target's privileged
914    // view, with `stub` overriding individual capability seams.
915    for &i in indices {
916        let Some(test_decl) = parsed[i].test() else {
917            continue;
918        };
919        // v0.25: test-case edges record in the test file, resolving bare
920        // names through the *target* unit's namespace.
921        refs.enter_file(
922            &parsed[i].identity_path(),
923            target_name,
924            parsed[i].is_synthetic(),
925        );
926        for case in &test_decl.cases {
927            check_test_case_body(
928                target_name,
929                target_kind,
930                case,
931                unit_tables,
932                unit_uses,
933                unit_consumes,
934                unit_consumes_aliases,
935                &mut errors,
936                refs,
937                tys,
938            );
939            let tier = case_effective_tier(case, test_decl);
940            check_faults_tier(case, tier, &mut errors);
941            if tier != bynk_syntax::ast::TestTier::System {
942                crossing.check_case(target_name, case, tier, &mut errors);
943            }
944        }
945        // v0.114: generative `property` blocks — check their `for all` bindings,
946        // `where` filter, and predicate body (testing track slice 2).
947        // v0.118: a `property` never carries a tier — `as <tier>` is a
948        // `case`-only affordance, and the grammar has no property-tier
949        // production (`PropertyDecl` has no tier field), so there is nothing to
950        // check here (#1662 removed an unreachable guard for it).
951        for prop in &test_decl.properties {
952            check_property_body(
953                target_name,
954                target_kind,
955                prop,
956                unit_tables,
957                unit_uses,
958                unit_consumes,
959                unit_consumes_aliases,
960                &mut errors,
961                refs,
962                tys,
963            );
964            crossing.check_property(target_name, prop, &mut errors);
965        }
966    }
967
968    errors
969}
970
971/// v0.118: wrap a single expression as a `{ tail: e }` block, so a `stub`
972/// value can be type-checked or lowered in the same op-body position a provider
973/// operation's tail occupies.
974///
975/// Dual-use (found during P5.4's move, not in the original slice plan):
976/// `stub_value_typechecks` (in this module) uses it for the checking path;
977/// `bynk-emit`'s `lower_stub_value_block` also calls it, qualified, to lower
978/// a `stub` RHS value in the same op-body tail position. `pub` for that
979/// second caller, same as every other dual-use function in this module.
980pub fn value_block(e: &Expr) -> Block {
981    Block {
982        statements: Vec::new(),
983        tail: Box::new(e.clone()),
984        span: e.span,
985        tail_leading_comments: Vec::new(),
986        implicit_tail: false,
987    }
988}
989
990/// v0.118: whether a `stub` value expression type-checks against the
991/// overridden capability op's declared return type (best-effort — a throwaway
992/// check against the target's privileged view). A mismatch drives
993/// `bynk.stub.rhs_type`.
994fn stub_value_typechecks(
995    e: &Expr,
996    op: &CapabilityOp,
997    resolved: &ResolvedCommons,
998    tys: &Arc<Types>,
999) -> bool {
1000    let block = value_block(e);
1001    let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
1002    let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
1003    let mut errs: Vec<CompileError> = Vec::new();
1004    checker::check_handler_body(
1005        resolved,
1006        checker::HandlerBodyCheck::new(&block, &op.return_type, &op.params, &[]),
1007        checker::CheckSinks {
1008            tys,
1009            expr_types: &mut expr_types,
1010            errors: &mut errs,
1011            refs: &mut RefSink::new(),
1012            hints: &mut HintSink::new(),
1013            locals: &mut LocalsSink::new(),
1014            requirements: &mut RequirementSink::new(),
1015            callees: &mut callees,
1016        },
1017    );
1018    errs.is_empty()
1019}
1020
1021/// Slice C: whether a `case` body uses a `Wire(…)` raw argument anywhere. A
1022/// `Wire` is only meaningful at `system` (it hands pre-validation input to the
1023/// real boundary); used at any other tier it is `bynk.test.wire_needs_system`.
1024fn block_uses_wire(block: &Block) -> bool {
1025    // Ported onto `bynk_syntax::ast::expr_children` (P5.4) — `bynk-emit`'s
1026    // `crate::emitter::walk_exprs` this used before the move is emission-
1027    // private and unreachable from `bynk-check`. `expr_children` is the
1028    // exhaustive total child iterator the checker already walks the same way
1029    // (see `context_checks.rs`/`checker.rs`); this reimplements the original
1030    // statement-value + tail walk faithfully, not a rewrite of its behaviour.
1031    fn contains_wire(e: &Expr) -> bool {
1032        matches!(e.kind, ExprKind::Wire(_))
1033            || bynk_syntax::ast::expr_children(e)
1034                .into_iter()
1035                .any(contains_wire)
1036    }
1037    for s in &block.statements {
1038        let e = match s {
1039            Statement::Let(l) => &l.value,
1040            Statement::EffectLet(l) => &l.value,
1041            Statement::Expect(x) => &x.value,
1042            Statement::Send(x) => &x.value,
1043            Statement::Do(d) => &d.value,
1044            Statement::Assign(a) => &a.value,
1045        };
1046        if contains_wire(e) {
1047            return true;
1048        }
1049    }
1050    contains_wire(&block.tail)
1051}
1052
1053/// A cross-context service call: `(context, service)`.
1054type CrossCall = (String, String);
1055
1056/// A same-context agent handler: `(agent, handler)`.
1057type AgentHandler = (String, String);
1058
1059/// #1737: what in the target reaches another context, for the
1060/// `bynk.tier.cross_context_needs_system` gate: each service, and each agent
1061/// handler, that does, with the cross-context call it reaches.
1062///
1063/// Below `system` a case runs in-process: a consumed context is not stood up,
1064/// and `stub` doubles capabilities only, never a context's services. So a case
1065/// that reaches another context's service (directly, or through a target
1066/// service or agent handler that calls one) had no collaborator to call, and
1067/// crashed at runtime on an `undefined` surface. Crossing a context boundary is
1068/// the `system` tier's job; this finds the test bodies that try it below.
1069///
1070/// A cross-context call is recognised the way the resolver resolves one
1071/// (`CrossContextInfo::resolve_prefix`): a method call whose receiver chain is a
1072/// consumed context's alias or qualified name, naming a service that context
1073/// declares. A service or agent handler reaches another context if its own
1074/// body makes such a call, or dispatches to an agent handler that
1075/// (transitively) does. An agent call is recognised inline
1076/// (`Counter(k).bump(…)`) and through a `let`-bound instance
1077/// (`let c = Counter(k)` then `c.bump(…)`).
1078struct CrossingServices<'a> {
1079    /// service name → the cross-context call it reaches.
1080    services: HashMap<String, CrossCall>,
1081    /// agent handler → the cross-context call it reaches.
1082    agents: HashMap<AgentHandler, CrossCall>,
1083    /// The target's own table, for recognising agent calls in a test body.
1084    table: Option<&'a UnitTable>,
1085    /// The target's consumed contexts and aliases, for direct calls in a body.
1086    consumed: &'a [String],
1087    aliases: Option<&'a HashMap<String, String>>,
1088    unit_tables: &'a HashMap<String, UnitTable>,
1089}
1090
1091impl<'a> CrossingServices<'a> {
1092    fn of(
1093        target_name: &str,
1094        unit_tables: &'a HashMap<String, UnitTable>,
1095        unit_consumes: &'a HashMap<String, Vec<String>>,
1096        unit_consumes_aliases: &'a HashMap<String, HashMap<String, String>>,
1097    ) -> Self {
1098        let mut this = CrossingServices {
1099            services: HashMap::new(),
1100            agents: HashMap::new(),
1101            table: unit_tables.get(target_name),
1102            consumed: unit_consumes
1103                .get(target_name)
1104                .map(Vec::as_slice)
1105                .unwrap_or(&[]),
1106            aliases: unit_consumes_aliases.get(target_name),
1107            unit_tables,
1108        };
1109        let Some(table) = this.table else {
1110            return this;
1111        };
1112        if this.consumed.is_empty() {
1113            return this;
1114        }
1115        // Each agent handler's own cross-context call (if any) and the agent
1116        // handlers it dispatches to, then a fixpoint over the dispatch edges.
1117        let mut agent_edges: HashMap<AgentHandler, Vec<AgentHandler>> = HashMap::new();
1118        for (agent, decl) in &table.agents {
1119            for h in &decl.handlers {
1120                let Some(method) = &h.method_name else {
1121                    continue;
1122                };
1123                let key = (agent.clone(), method.name.clone());
1124                let (hit, edges) = this.scan(&h.body);
1125                if let Some(hit) = hit {
1126                    this.agents.insert(key.clone(), hit);
1127                }
1128                agent_edges.insert(key, edges);
1129            }
1130        }
1131        loop {
1132            let mut changed = false;
1133            for (key, edges) in &agent_edges {
1134                if this.agents.contains_key(key) {
1135                    continue;
1136                }
1137                if let Some(hit) = edges.iter().find_map(|e| this.agents.get(e).cloned()) {
1138                    this.agents.insert(key.clone(), hit);
1139                    changed = true;
1140                }
1141            }
1142            if !changed {
1143                break;
1144            }
1145        }
1146        for (name, decl) in &table.services {
1147            for h in &decl.handlers {
1148                let (hit, edges) = this.scan(&h.body);
1149                let reached =
1150                    hit.or_else(|| edges.iter().find_map(|e| this.agents.get(e).cloned()));
1151                if let Some(reached) = reached {
1152                    this.services.entry(name.clone()).or_insert(reached);
1153                }
1154            }
1155        }
1156        this
1157    }
1158
1159    /// The consumed context `chain` names, by alias or qualified name.
1160    fn resolve(&self, chain: &str) -> Option<&String> {
1161        self.aliases
1162            .and_then(|a| a.get(chain))
1163            .or_else(|| self.consumed.iter().find(|c| *c == chain))
1164    }
1165
1166    /// `e` as a cross-context service call.
1167    fn cross_call(&self, e: &Expr) -> Option<CrossCall> {
1168        let ExprKind::MethodCall {
1169            receiver, method, ..
1170        } = &e.kind
1171        else {
1172            return None;
1173        };
1174        let ctx = self.resolve(&receiver_chain(receiver)?)?;
1175        let callee = self.unit_tables.get(ctx)?;
1176        callee
1177            .services
1178            .contains_key(&method.name)
1179            .then(|| (ctx.clone(), method.name.clone()))
1180    }
1181
1182    /// `e` as an agent handler call, inline (`Counter(k).bump(…)`) or through
1183    /// an instance `bound` by a `let` (`c.bump(…)`).
1184    fn dispatch(&self, e: &Expr, bound: &HashMap<String, String>) -> Option<AgentHandler> {
1185        let table = self.table?;
1186        let ExprKind::MethodCall {
1187            receiver, method, ..
1188        } = &e.kind
1189        else {
1190            return None;
1191        };
1192        let agent = match &receiver.kind {
1193            ExprKind::Call { name, .. } if table.agents.contains_key(&name.name) => {
1194                name.name.clone()
1195            }
1196            ExprKind::Ident(x) => bound.get(&x.name)?.clone(),
1197            _ => return None,
1198        };
1199        Some((agent, method.name.clone()))
1200    }
1201
1202    /// The first cross-context call in `block` (in source order), and every
1203    /// agent handler it dispatches to.
1204    fn scan(&self, block: &Block) -> (Option<CrossCall>, Vec<AgentHandler>) {
1205        let bound = self.agent_bindings(block);
1206        let mut hit = None;
1207        let mut edges = Vec::new();
1208        for e in exprs_in_order(block) {
1209            if hit.is_none() {
1210                hit = self.cross_call(e);
1211            }
1212            if let Some(edge) = self.dispatch(e, &bound) {
1213                edges.push(edge);
1214            }
1215        }
1216        (hit, edges)
1217    }
1218
1219    /// The names `block` binds, at any depth, to an agent instance
1220    /// (`let c = Counter(k)`), mapped to the agent.
1221    fn agent_bindings(&self, block: &Block) -> HashMap<String, String> {
1222        let mut bound = HashMap::new();
1223        let Some(table) = self.table else {
1224            return bound;
1225        };
1226        for b in blocks_deep(block) {
1227            for s in &b.statements {
1228                if let Statement::Let(l) | Statement::EffectLet(l) = s
1229                    && let ExprKind::Call { name, .. } = &l.value.kind
1230                    && table.agents.contains_key(&name.name)
1231                {
1232                    bound.insert(l.name.name.clone(), name.name.clone());
1233                }
1234            }
1235        }
1236        bound
1237    }
1238
1239    /// Report each call in a non-`system` test body that reaches another
1240    /// context: a target service that crosses (`check.call(…)`), an agent
1241    /// handler that crosses (`Counter(k).bump(…)`), or a consumed context's
1242    /// service called directly. `subject` names the body (``case `"…"` ``),
1243    /// and `below` says why it runs in-process.
1244    fn check_block(
1245        &self,
1246        target_name: &str,
1247        subject: &str,
1248        below: &str,
1249        body: &Block,
1250        errors: &mut Vec<CompileError>,
1251    ) {
1252        if self.consumed.is_empty() {
1253            return;
1254        }
1255        let bound = self.agent_bindings(body);
1256        // A name a `let` in the body rebinds is the binding, not a service.
1257        let shadowed: HashSet<String> = blocks_deep(body)
1258            .iter()
1259            .flat_map(|b| &b.statements)
1260            .filter_map(|s| match s {
1261                Statement::Let(l) | Statement::EffectLet(l) => Some(l.name.name.clone()),
1262                _ => None,
1263            })
1264            .collect();
1265        for e in exprs_in_order(body) {
1266            let ExprKind::MethodCall { receiver, .. } = &e.kind else {
1267                continue;
1268            };
1269            let through = match &receiver.kind {
1270                // `svc.call(…)` / `svc.GET(…)` / … on a target service.
1271                ExprKind::Ident(svc) if !shadowed.contains(&svc.name) => self
1272                    .services
1273                    .get(&svc.name)
1274                    .map(|reached| (format!("`{}`", svc.name), Some(svc.name.clone()), reached)),
1275                _ => None,
1276            };
1277            let through = through.or_else(|| {
1278                let (agent, handler) = self.dispatch(e, &bound)?;
1279                let reached = self.agents.get(&(agent.clone(), handler.clone()))?;
1280                Some((format!("`{agent}.{handler}`"), None, reached))
1281            });
1282            let message = match through {
1283                Some((via, _, (ctx, svc))) => {
1284                    format!(
1285                        "{subject} calls {via}, which calls `{ctx}.{svc}` in another context, but {below}"
1286                    )
1287                }
1288                None => match self.cross_call(e) {
1289                    Some((ctx, svc)) => {
1290                        format!("{subject} calls `{ctx}.{svc}` in another context, but {below}")
1291                    }
1292                    None => continue,
1293                },
1294            };
1295            let service = match &receiver.kind {
1296                ExprKind::Ident(svc) if self.services.contains_key(&svc.name) => {
1297                    Some(svc.name.clone())
1298                }
1299                _ => None,
1300            };
1301            let promote = match service {
1302                Some(s) => format!(
1303                    "move it into a `system` suite (`suite {target_name} as system {{ … }}`), where the service is addressed by its context path (`{target_name}.{s}(…)`)"
1304                ),
1305                None => format!(
1306                    "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"
1307                ),
1308            };
1309            errors.push(
1310                CompileError::new("bynk.tier.cross_context_needs_system", e.span, message)
1311                    .with_note(format!(
1312                        "below `system` a test runs in-process, with no other context stood up to call (`stub` doubles capabilities, not a context's services); {promote}"
1313                    )),
1314            );
1315        }
1316    }
1317
1318    /// The gate for a non-`system` `case`.
1319    fn check_case(
1320        &self,
1321        target_name: &str,
1322        case: &Case,
1323        tier: TestTier,
1324        errors: &mut Vec<CompileError>,
1325    ) {
1326        let tier = tier.as_str();
1327        // "an integration", but "a unit" (said with a "y" sound).
1328        let article = if tier == "integration" { "an" } else { "a" };
1329        self.check_block(
1330            target_name,
1331            &format!("case `\"{}\"`", case.name),
1332            &format!("it is {article} `{tier}`-tier case"),
1333            &case.body,
1334            errors,
1335        );
1336    }
1337
1338    /// The gate for a `property`, which has no tier and always runs in-process.
1339    fn check_property(
1340        &self,
1341        target_name: &str,
1342        prop: &PropertyDecl,
1343        errors: &mut Vec<CompileError>,
1344    ) {
1345        self.check_block(
1346            target_name,
1347            &format!("property `\"{}\"`", prop.name),
1348            "a property runs in-process (it generates, and has no `system` tier)",
1349            &prop.forall.body,
1350            errors,
1351        );
1352    }
1353}
1354
1355/// `e` as a dotted name (`Vault`, `demo.vault`), or `None` for anything that
1356/// is not a chain of identifiers.
1357fn receiver_chain(e: &Expr) -> Option<String> {
1358    match &e.kind {
1359        ExprKind::Ident(id) => Some(id.name.clone()),
1360        ExprKind::FieldAccess { receiver, field } => {
1361            Some(format!("{}.{}", receiver_chain(receiver)?, field.name))
1362        }
1363        _ => None,
1364    }
1365}
1366
1367/// Every expression in `block` at any depth, in source order: each statement's
1368/// expressions, then the tail, each visited before its children (via
1369/// [`bynk_syntax::ast::expr_children`]).
1370fn exprs_in_order(block: &Block) -> Vec<&Expr> {
1371    let mut roots: Vec<&Expr> = Vec::new();
1372    for s in &block.statements {
1373        bynk_syntax::ast::statement_exprs(s, &mut roots);
1374    }
1375    roots.push(&block.tail);
1376    // A stack popped from the end, so push in reverse to visit in order.
1377    let mut stack: Vec<&Expr> = roots.into_iter().rev().collect();
1378    let mut out = Vec::new();
1379    while let Some(e) = stack.pop() {
1380        out.push(e);
1381        stack.extend(bynk_syntax::ast::expr_children(e).into_iter().rev());
1382    }
1383    out
1384}
1385
1386/// `block` and every block nested in it (a block expression, an `if`'s
1387/// branches, a `match` arm's block body), so a `let` at any depth is seen.
1388fn blocks_deep(block: &Block) -> Vec<&Block> {
1389    let mut out = vec![block];
1390    for e in exprs_in_order(block) {
1391        match &e.kind {
1392            ExprKind::Block(b) => out.push(b),
1393            ExprKind::If {
1394                then_block,
1395                else_block,
1396                ..
1397            } => {
1398                out.push(then_block);
1399                out.push(else_block);
1400            }
1401            ExprKind::Match { arms, .. } => {
1402                for arm in arms {
1403                    if let MatchBody::Block(b) = &arm.body {
1404                        out.push(b);
1405                    }
1406                }
1407            }
1408            _ => {}
1409        }
1410    }
1411    out
1412}
1413
1414/// #1706: whether a `case` body claims a fault anywhere (`expect <call>
1415/// faults`). The claim observes a call *throwing*, which only an in-process
1416/// call does: at `system` a handler's fault crosses the real Worker boundary
1417/// as an error response, never a throw at the harness, so the claim could
1418/// never hold there (`bynk.test.faults_needs_in_process`).
1419fn block_uses_faults(block: &Block) -> bool {
1420    fn contains_faults(e: &Expr) -> bool {
1421        matches!(e.kind, ExprKind::Faults(_))
1422            || bynk_syntax::ast::expr_children(e)
1423                .into_iter()
1424                .any(contains_faults)
1425    }
1426    let mut exprs = Vec::new();
1427    for s in &block.statements {
1428        bynk_syntax::ast::statement_exprs(s, &mut exprs);
1429    }
1430    exprs.into_iter().any(contains_faults) || contains_faults(&block.tail)
1431}
1432
1433/// #1706: report a `system`-tier case that claims a fault — see
1434/// [`block_uses_faults`].
1435fn check_faults_tier(
1436    case: &Case,
1437    tier: bynk_syntax::ast::TestTier,
1438    errors: &mut Vec<CompileError>,
1439) {
1440    if tier == bynk_syntax::ast::TestTier::System && block_uses_faults(&case.body) {
1441        errors.push(
1442            CompileError::new(
1443                "bynk.test.faults_needs_in_process",
1444                case.name_span,
1445                format!(
1446                    "case `\"{}\"` claims a fault with `faults`, but is a `system`-tier case",
1447                    case.name
1448                ),
1449            )
1450            .with_note(
1451                "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`",
1452            ),
1453        );
1454    }
1455}
1456
1457/// #706: whether a `case` body drives an effect-let `by Nobody` — the "no
1458/// credential" principal. It is only meaningful at `system` (there is no auth
1459/// seam to reject a missing credential at `unit`), so a non-`system` case using
1460/// it is `bynk.test.credential_needs_system`.
1461fn block_uses_nobody(block: &Block) -> bool {
1462    block.statements.iter().any(|s| {
1463        matches!(s, Statement::EffectLet(l)
1464            if l.principal.as_ref().is_some_and(|p| p.actor.name == "Nobody"))
1465    })
1466}
1467
1468/// Register a synthetic call-record type per capability operation of the target
1469/// context (v0.117, testing track slice 5), so `trace(Cap.op)` — typed
1470/// `List[<CallRecord>]` — supports field access on its records. The record's
1471/// fields are the operation's parameters.
1472pub fn register_call_record_types(
1473    resolved: &mut ResolvedCommons,
1474    target_name: &str,
1475    unit_tables: &HashMap<String, UnitTable>,
1476) {
1477    let Some(table) = unit_tables.get(target_name) else {
1478        return;
1479    };
1480    // #291: a consumed adapter's capabilities are seams too (its `trace` and
1481    // `with` records); the target's own wins a name clash.
1482    let mut caps: Vec<(&String, &CapabilityDecl)> = table.capabilities.iter().collect();
1483    for (n, d) in flattened_platform_capabilities(unit_tables, resolved) {
1484        if !table.capabilities.contains_key(n) {
1485            caps.push((n, d));
1486        }
1487    }
1488    for (cap_name, decl) in caps {
1489        for op in &decl.ops {
1490            let fields: Vec<RecordField> = op
1491                .params
1492                .iter()
1493                .map(|p| RecordField {
1494                    trivia: Default::default(),
1495                    name: p.name.clone(),
1496                    type_ref: p.type_ref.clone(),
1497                    refinement: None,
1498                    init: None,
1499                    span: p.span,
1500                })
1501                .collect();
1502            let name = checker::call_record_type_name(cap_name, &op.name.name);
1503            resolved.types.insert(
1504                name.clone(),
1505                Arc::new(TypeDecl {
1506                    type_params: Vec::new(),
1507                    name: Ident {
1508                        name,
1509                        span: op.name.span,
1510                    },
1511                    body: TypeBody::Record(RecordBody {
1512                        trailing_comments: Default::default(),
1513                        fields,
1514                        span: op.name.span,
1515                    }),
1516                    documentation: None,
1517                    span: op.name.span,
1518                    trivia: Trivia::default(),
1519                }),
1520            );
1521        }
1522    }
1523}
1524
1525fn target_test_actors(table: Option<&UnitTable>) -> HashMap<String, bynk_syntax::ast::ActorDecl> {
1526    table.map(|t| t.actors.clone()).unwrap_or_default()
1527}
1528
1529fn target_test_services(table: Option<&UnitTable>) -> HashMap<String, checker::TestServiceSig> {
1530    use bynk_syntax::ast::ServiceProtocol;
1531    let Some(t) = table else {
1532        return HashMap::new();
1533    };
1534    t.services
1535        .iter()
1536        .map(|(name, decl)| {
1537            let protocol = match &decl.protocol {
1538                ServiceProtocol::Call => None,
1539                ServiceProtocol::Http => Some("http".to_string()),
1540                ServiceProtocol::Cron => Some("cron".to_string()),
1541                ServiceProtocol::Queue { .. } => Some("queue".to_string()),
1542                ServiceProtocol::WebSocket { .. } => Some("websocket".to_string()),
1543                ServiceProtocol::Events { .. } => Some("events".to_string()),
1544            };
1545            let handlers = decl
1546                .handlers
1547                .iter()
1548                .map(|h| checker::TestHandler {
1549                    kind: h.kind.clone(),
1550                    params: h.params.clone(),
1551                    by_clause: h.by_clause.clone(),
1552                    span: h.span,
1553                })
1554                .collect();
1555            (name.clone(), checker::TestServiceSig { protocol, handlers })
1556        })
1557        .collect()
1558}
1559
1560/// #291: the platform capabilities the target flattens in from a consumed
1561/// adapter (`consumes bynk { Logger }`) — seams of the unit under test, like
1562/// the capabilities it declares — with their declarations, sorted by name.
1563fn flattened_platform_capabilities<'a>(
1564    unit_tables: &'a HashMap<String, UnitTable>,
1565    resolved: &ResolvedCommons,
1566) -> Vec<(&'a String, &'a CapabilityDecl)> {
1567    let mut out: Vec<_> = resolved
1568        .cross_context
1569        .flattened_caps
1570        .iter()
1571        .filter_map(|(cap, owner)| {
1572            let t = unit_tables.get(owner)?;
1573            if t.kind != Some(UnitKind::Adapter) {
1574                return None;
1575            }
1576            t.capabilities.get_key_value(cap)
1577        })
1578        .collect();
1579    out.sort_by_key(|(cap, _)| cap.as_str());
1580    out
1581}
1582
1583/// #291: a platform capability the target flattens in from a consumed
1584/// **adapter** (`bynk`'s `Logger`, by `consumes bynk { Logger }`) is a seam of
1585/// the unit under test too, so a test body may observe it (`expect Logger.info
1586/// called once`), as it may already `stub` it. A capability the target declares
1587/// itself wins on a name clash.
1588fn add_consumed_adapter_capabilities(
1589    map: &mut HashMap<String, checker::CapabilityInfo>,
1590    unit_tables: &HashMap<String, UnitTable>,
1591    resolved: &ResolvedCommons,
1592    tys: &Arc<Types>,
1593) {
1594    for (name, decl) in flattened_platform_capabilities(unit_tables, resolved) {
1595        if map.contains_key(name) {
1596            continue;
1597        }
1598        let ops = decl
1599            .ops
1600            .iter()
1601            .map(|op| build_capability_op_info(op, &resolved.types, tys))
1602            .collect();
1603        map.insert(
1604            name.clone(),
1605            checker::CapabilityInfo {
1606                name: name.clone(),
1607                ops,
1608            },
1609        );
1610    }
1611}
1612
1613/// Type-check a test `case`/`property` body against the target unit's privileges,
1614/// returning the inferred `expr_types` map and the `Callee` classification
1615/// recorded alongside it. The **check** path feeds real diagnostic/ref sinks;
1616/// the **emit** path reuses it with throwaway sinks to give the case-body
1617/// lowering full type information (so collection kernels — notably
1618/// `trace(Cap.op)`'s `List[…]` methods — dispatch on the receiver's checked
1619/// type) *and* full `Callee` information (P6.21 review: the emit path's own
1620/// `callees` accumulator was previously built here and silently discarded —
1621/// `bynk-emit`'s `synthetic_typed_commons_for_target` never received it, so
1622/// `Callee::Intrinsic`/`Store`/etc. were never recorded for anything inside a
1623/// `.test.bynk` body, even though this function computed them correctly all
1624/// along).
1625#[allow(clippy::too_many_arguments)]
1626pub fn typecheck_case_body(
1627    target_name: &str,
1628    body: &Block,
1629    unit_span: Span,
1630    unit_tables: &HashMap<String, UnitTable>,
1631    resolved: &ResolvedCommons,
1632    errors: &mut Vec<CompileError>,
1633    refs: &mut RefSink,
1634    // v0.119: bindings already in scope for the body — empty for a `case`, the
1635    // `run: List[Step]` binding for a history property.
1636    initial_scope: HashMap<String, checker::TyId>,
1637    tys: &Arc<Types>,
1638) -> (
1639    HashMap<ExprId, checker::TypedExpr>,
1640    HashMap<ExprId, checker::Callee>,
1641) {
1642    let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
1643    let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
1644    // Synthesise an Effect[Result[(), ValidationError]] return type as a
1645    // stand-in for Effect[Result[(), ExpectationError]]. v0.7 doesn't model an
1646    // explicit ExpectationError type — the runtime catches it instead.
1647    let synthetic_return = TypeRef::Effect(
1648        Box::new(TypeRef::Result(
1649            Box::new(TypeRef::Unit(unit_span)),
1650            Box::new(TypeRef::ValidationError(unit_span)),
1651            unit_span,
1652        )),
1653        unit_span,
1654    );
1655
1656    // Capabilities of the target context, if any (so the test body can
1657    // call capabilities directly when targeting a context).
1658    let mut capability_info_map: HashMap<String, checker::CapabilityInfo> = HashMap::new();
1659    if let Some(table) = unit_tables.get(target_name) {
1660        for (name, decl) in &table.capabilities {
1661            let ops = decl
1662                .ops
1663                .iter()
1664                .map(|op| build_capability_op_info(op, &resolved.types, tys))
1665                .collect();
1666            capability_info_map.insert(
1667                name.clone(),
1668                checker::CapabilityInfo {
1669                    name: name.clone(),
1670                    ops,
1671                },
1672            );
1673        }
1674    }
1675    add_consumed_adapter_capabilities(&mut capability_info_map, unit_tables, resolved, tys);
1676
1677    // All declared capabilities are implicitly "given" inside a test body;
1678    // the test runner wires them via the mocked deps. We feed the same map
1679    // to both `capabilities` (in-scope) and `declared_capabilities`.
1680    let given_declared: Vec<String> = capability_info_map.keys().cloned().collect();
1681
1682    let return_ty = checker::resolve_type_ref(&synthetic_return, &resolved.types, tys).unwrap();
1683    let return_ty_span = unit_span;
1684    // Test bodies record no hints (out of v0.27 scope) — a throwaway sink.
1685    let mut no_hints = HintSink::new();
1686    let mut no_locals = LocalsSink::new();
1687    // Test bodies record no capability requirements either — muted sink.
1688    let mut no_requirements = RequirementSink::new();
1689    let _ = checker::check_body(
1690        resolved,
1691        body,
1692        return_ty,
1693        return_ty_span,
1694        initial_scope,
1695        checker::CapabilityCtx {
1696            capabilities: capability_info_map.clone(),
1697            declared_capabilities: capability_info_map,
1698            given_remaining: given_declared.iter().cloned().collect(),
1699            given_used: HashSet::new(),
1700            given_entries: Vec::new(),
1701            given_anchor: None,
1702        },
1703        target_test_services(unit_tables.get(target_name)),
1704        target_test_actors(unit_tables.get(target_name)),
1705        None,
1706        checker::CheckSinks {
1707            tys,
1708            expr_types: &mut expr_types,
1709            errors,
1710            refs,
1711            hints: &mut no_hints,
1712            locals: &mut no_locals,
1713            requirements: &mut no_requirements,
1714            callees: &mut callees,
1715        },
1716    );
1717    (expr_types, callees)
1718}
1719
1720#[allow(clippy::too_many_arguments)]
1721fn check_test_case_body(
1722    target_name: &str,
1723    target_kind: UnitKind,
1724    case: &Case,
1725    unit_tables: &HashMap<String, UnitTable>,
1726    unit_uses: &HashMap<String, Vec<String>>,
1727    unit_consumes: &HashMap<String, Vec<String>>,
1728    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
1729    errors: &mut Vec<CompileError>,
1730    refs: &mut RefSink,
1731    tys: &Arc<Types>,
1732) {
1733    let Some((mut resolved, _)) = build_privileged_resolved(
1734        target_name,
1735        unit_tables,
1736        unit_uses,
1737        unit_consumes,
1738        unit_consumes_aliases,
1739    ) else {
1740        return;
1741    };
1742    register_call_record_types(&mut resolved, target_name, unit_tables);
1743    let _ = target_kind;
1744    let _ = typecheck_case_body(
1745        target_name,
1746        &case.body,
1747        case.span,
1748        unit_tables,
1749        &resolved,
1750        errors,
1751        refs,
1752        HashMap::new(),
1753        tys,
1754    );
1755    // Don't enforce return-type equality; the test runner discards the
1756    // tail expression and recovers success/failure from expectation outcome.
1757    // Don't enforce "every given used" — capabilities are implicitly
1758    // available in a test body.
1759
1760    // v0.115: flag a `case` that merely restates a contract already declared at
1761    // the source (`bynk.contract.restated_by_test`) — an `expect` that is
1762    // α-equivalent to an `ensures` clause over the same bound arguments. The dev
1763    // guard and the runner attack already check it. Conservative: under-flagging
1764    // is acceptable, over-flagging is not.
1765    check_restated_contract(&case.body, &resolved, errors);
1766}
1767
1768/// v0.115: within a test body, flag an `expect` that re-states a contract's
1769/// `ensures`. Fires only on the clearest restatement: a binding `let r = f(args)`
1770/// (or `r <- f(args)`) of a contracted free function's result, followed by an
1771/// `expect E` that is α-equivalent to one of `f`'s `ensures` predicates under the
1772/// substitution `result → r`, `params → args`. Syntactic — never semantic — so a
1773/// merely-equivalent (but differently written) test is not flagged.
1774fn check_restated_contract(
1775    body: &Block,
1776    resolved: &ResolvedCommons,
1777    errors: &mut Vec<CompileError>,
1778) {
1779    // Map each locally-bound name to the contracted free function + call args it
1780    // was bound from (`let r = f(a, b)`).
1781    let mut bound: HashMap<String, (&FnDecl, &[Expr])> = HashMap::new();
1782    for stmt in &body.statements {
1783        let (name, value) = match stmt {
1784            Statement::Let(l) | Statement::EffectLet(l) => (&l.name.name, &l.value),
1785            _ => continue,
1786        };
1787        if let ExprKind::Call {
1788            name: callee, args, ..
1789        } = &value.kind
1790            && let Some(f) = resolved.fns.get(&callee.name)
1791            && matches!(&f.name, FnName::Free(_))
1792            && !f.ensures.is_empty()
1793            && f.params.len() == args.len()
1794        {
1795            bound.insert(name.clone(), (f, args.as_slice()));
1796        }
1797    }
1798    if bound.is_empty() {
1799        return;
1800    }
1801    for stmt in &body.statements {
1802        let Statement::Expect(e) = stmt else { continue };
1803        for (result_name, (f, args)) in &bound {
1804            // subst: result → r, each param → its call argument.
1805            let result_ident = Expr {
1806                id: ExprId::SYNTHETIC,
1807                kind: ExprKind::Ident(Ident {
1808                    name: result_name.clone(),
1809                    span: e.span,
1810                }),
1811                span: e.span,
1812            };
1813            let mut subst: HashMap<&str, &Expr> = HashMap::new();
1814            subst.insert("result", &result_ident);
1815            for (p, a) in f.params.iter().zip(args.iter()) {
1816                subst.insert(p.name.name.as_str(), a);
1817            }
1818            for c in &f.ensures {
1819                if expr_alpha_eq_subst(&c.predicate, &e.value, &subst) {
1820                    let FnName::Free(fname) = &f.name else {
1821                        continue;
1822                    };
1823                    errors.push(
1824                        CompileError::new(
1825                            "bynk.contract.restated_by_test",
1826                            e.span,
1827                            format!(
1828                                "this `expect` restates the `ensures {}` contract of `{}`, which is already checked at every call and by the runner",
1829                                c.name.name, fname.name
1830                            ),
1831                        )
1832                        .with_note(
1833                            "a contract is checked everywhere for free — delete the restating test, or keep a `case` only for a specific witnessed value",
1834                        ),
1835                    );
1836                    break;
1837                }
1838            }
1839        }
1840    }
1841}
1842
1843/// Structural (α-)equality of two predicate expressions, ignoring spans, where a
1844/// bare identifier in `pattern` that appears in `subst` must match the
1845/// corresponding substituted expression in `actual` (the rest compares by shape).
1846/// Deliberately conservative — only the operators/leaves a contract predicate can
1847/// contain are compared; anything unrecognised is unequal.
1848fn expr_alpha_eq_subst(pattern: &Expr, actual: &Expr, subst: &HashMap<&str, &Expr>) -> bool {
1849    if let ExprKind::Ident(id) = &pattern.kind
1850        && let Some(replacement) = subst.get(id.name.as_str())
1851    {
1852        return expr_struct_eq(replacement, actual);
1853    }
1854    match (&pattern.kind, &actual.kind) {
1855        (ExprKind::Ident(a), ExprKind::Ident(b)) => a.name == b.name,
1856        (ExprKind::IntLit { value: a, .. }, ExprKind::IntLit { value: b, .. }) => a == b,
1857        (ExprKind::BoolLit(a), ExprKind::BoolLit(b)) => a == b,
1858        (ExprKind::StrLit(a), ExprKind::StrLit(b)) => a == b,
1859        (ExprKind::Paren(a), _) => expr_alpha_eq_subst(a, actual, subst),
1860        (_, ExprKind::Paren(b)) => expr_alpha_eq_subst(pattern, b, subst),
1861        (ExprKind::BinOp(oa, la, ra), ExprKind::BinOp(ob, lb, rb)) => {
1862            oa == ob && expr_alpha_eq_subst(la, lb, subst) && expr_alpha_eq_subst(ra, rb, subst)
1863        }
1864        (ExprKind::UnaryOp(oa, a), ExprKind::UnaryOp(ob, b)) => {
1865            oa == ob && expr_alpha_eq_subst(a, b, subst)
1866        }
1867        (
1868            ExprKind::MethodCall {
1869                receiver: ra,
1870                method: ma,
1871                args: aa,
1872                ..
1873            },
1874            ExprKind::MethodCall {
1875                receiver: rb,
1876                method: mb,
1877                args: ab,
1878                ..
1879            },
1880        ) => {
1881            ma.name == mb.name
1882                && aa.len() == ab.len()
1883                && expr_alpha_eq_subst(ra, rb, subst)
1884                && aa
1885                    .iter()
1886                    .zip(ab.iter())
1887                    .all(|(x, y)| expr_alpha_eq_subst(x, y, subst))
1888        }
1889        _ => false,
1890    }
1891}
1892
1893/// Plain structural equality of two expressions ignoring spans — used to compare
1894/// a substituted argument against its use in the test predicate.
1895fn expr_struct_eq(a: &Expr, b: &Expr) -> bool {
1896    match (&a.kind, &b.kind) {
1897        (ExprKind::Ident(x), ExprKind::Ident(y)) => x.name == y.name,
1898        (ExprKind::IntLit { value: x, .. }, ExprKind::IntLit { value: y, .. }) => x == y,
1899        (ExprKind::BoolLit(x), ExprKind::BoolLit(y)) => x == y,
1900        (ExprKind::StrLit(x), ExprKind::StrLit(y)) => x == y,
1901        (ExprKind::Paren(x), _) => expr_struct_eq(x, b),
1902        (_, ExprKind::Paren(y)) => expr_struct_eq(a, y),
1903        (ExprKind::BinOp(oa, la, ra), ExprKind::BinOp(ob, lb, rb)) => {
1904            oa == ob && expr_struct_eq(la, lb) && expr_struct_eq(ra, rb)
1905        }
1906        (ExprKind::UnaryOp(oa, x), ExprKind::UnaryOp(ob, y)) => oa == ob && expr_struct_eq(x, y),
1907        (
1908            ExprKind::MethodCall {
1909                receiver: ra,
1910                method: ma,
1911                args: aa,
1912                ..
1913            },
1914            ExprKind::MethodCall {
1915                receiver: rb,
1916                method: mb,
1917                args: ab,
1918                ..
1919            },
1920        ) => {
1921            ma.name == mb.name
1922                && aa.len() == ab.len()
1923                && expr_struct_eq(ra, rb)
1924                && aa.iter().zip(ab.iter()).all(|(x, y)| expr_struct_eq(x, y))
1925        }
1926        _ => false,
1927    }
1928}
1929
1930/// v0.114: the recursion cap for property-binding generability (mirrors the
1931/// checker's `MOCK_DEPTH` for bare `Val`).
1932pub const PROP_GEN_DEPTH: u32 = 12;
1933
1934/// Whether a `for all x: T` binding's type is refinement-generable: refined
1935/// types must not carry a `Matches` predicate (no refinement-driven generator),
1936/// and sums/records must have every component recursively generable within the
1937/// depth cap. Mirrors the checker's `can_mock_bare`.
1938pub fn prop_binding_generable(
1939    ty: checker::TyId,
1940    types: &HashMap<String, Arc<TypeDecl>>,
1941    depth: u32,
1942    tys: &Arc<Types>,
1943) -> bool {
1944    if depth == 0 {
1945        return false;
1946    }
1947    match &*tys.get(ty) {
1948        checker::Ty::Base(_) => true,
1949        checker::Ty::Named { name, .. } => {
1950            let Some(decl) = types.get(name) else {
1951                return false;
1952            };
1953            match &decl.body {
1954                TypeBody::Refined { refinement, .. } | TypeBody::Opaque { refinement, .. } => {
1955                    !refinement.as_ref().is_some_and(|r| {
1956                        r.predicates
1957                            .iter()
1958                            .any(|p| matches!(p.kind, PredKind::Matches(_)))
1959                    })
1960                }
1961                TypeBody::Sum(s) => s.variants.first().is_some_and(|v| {
1962                    v.payload.iter().all(|f| {
1963                        checker::resolve_type_ref(&f.type_ref, types, tys)
1964                            .is_some_and(|t| prop_binding_generable(t, types, depth - 1, tys))
1965                    })
1966                }),
1967                TypeBody::Record(r) => r.fields.iter().all(|f| {
1968                    checker::resolve_type_ref(&f.type_ref, types, tys)
1969                        .is_some_and(|t| prop_binding_generable(t, types, depth - 1, tys))
1970                }),
1971            }
1972        }
1973        _ => false,
1974    }
1975}
1976
1977/// The refinement of a resolved refined/opaque named type, if any — used by the
1978/// conservative restates-refinement check.
1979fn named_refinement<'a>(
1980    ty: checker::TyId,
1981    types: &'a HashMap<String, Arc<TypeDecl>>,
1982    tys: &Arc<Types>,
1983) -> Option<&'a Refinement> {
1984    let node = tys.get(ty);
1985    let checker::Ty::Named { name, .. } = &*node else {
1986        return None;
1987    };
1988    match &types.get(name)?.body {
1989        TypeBody::Refined { refinement, .. } | TypeBody::Opaque { refinement, .. } => {
1990            refinement.as_ref()
1991        }
1992        _ => None,
1993    }
1994}
1995
1996/// v0.114 (DECISION P): does `pred` merely restate a refinement `bound_var`
1997/// already guarantees? A **conservative, syntactic** check — it fires only when
1998/// the predicate is exactly the refinement over the bound variable, never
1999/// guessing (under-flagging is acceptable; over-flagging is not). Handles the
2000/// `Positive` (`v > 0` / `v >= 1`) and `NonNegative` (`v >= 0`) numeric cases.
2001fn predicate_restates_refinement(pred: &Expr, bound_var: &str, refinement: &Refinement) -> bool {
2002    let ExprKind::BinOp(op, lhs, rhs) = &pred.kind else {
2003        return false;
2004    };
2005    // `<var> <op> <int-literal>` only.
2006    let ExprKind::Ident(id) = &lhs.kind else {
2007        return false;
2008    };
2009    if id.name != bound_var {
2010        return false;
2011    }
2012    let ExprKind::IntLit { value: n, .. } = &rhs.kind else {
2013        return false;
2014    };
2015    let n = *n;
2016    let positive = refinement
2017        .predicates
2018        .iter()
2019        .any(|p| matches!(p.kind, PredKind::Positive));
2020    let non_negative = refinement
2021        .predicates
2022        .iter()
2023        .any(|p| matches!(p.kind, PredKind::NonNegative));
2024    match op {
2025        // `v > 0` / `v >= 1` restate `Positive`.
2026        BinOp::Gt if n == 0 => positive,
2027        BinOp::GtEq if n == 1 => positive,
2028        // `v >= 0` restates `NonNegative`.
2029        BinOp::GtEq if n == 0 => non_negative,
2030        _ => false,
2031    }
2032}
2033
2034/// v0.119 (DECISION D): which state-projection rewrite maps a history predicate
2035/// back into the space an `invariant` / `transition` is written in.
2036#[derive(Clone, Copy)]
2037enum HistoryRestate {
2038    /// An `invariant` reads bare state fields: `s.new.F` ≡ `F`.
2039    Invariant,
2040    /// A `transition` reads `old` / `new`: `s.old` ≡ `old`, `s.new` ≡ `new`.
2041    Transition,
2042}
2043
2044/// `Some(field)` when `e` is `s.new.<field>` (the reached-state projection an
2045/// invariant-restating history predicate uses).
2046fn as_new_field<'a>(e: &'a Expr, s: &str) -> Option<&'a str> {
2047    let ExprKind::FieldAccess { receiver, field } = &e.kind else {
2048        return None;
2049    };
2050    let ExprKind::FieldAccess {
2051        receiver: inner,
2052        field: which,
2053    } = &receiver.kind
2054    else {
2055        return None;
2056    };
2057    let ExprKind::Ident(id) = &inner.kind else {
2058        return None;
2059    };
2060    (id.name == s && which.name == "new").then_some(field.name.as_str())
2061}
2062
2063/// `Some("old"|"new")` when `e` is `s.old` / `s.new` (the step projections a
2064/// transition-restating history predicate uses).
2065fn as_step_root<'a>(e: &'a Expr, s: &str) -> Option<&'a str> {
2066    let ExprKind::FieldAccess { receiver, field } = &e.kind else {
2067        return None;
2068    };
2069    let ExprKind::Ident(id) = &receiver.kind else {
2070        return None;
2071    };
2072    (id.name == s && (field.name == "old" || field.name == "new")).then_some(field.name.as_str())
2073}
2074
2075/// Conservative, span-insensitive structural match (DECISION D): does the history
2076/// predicate `body` (over the step binding `s`) restate the declared predicate
2077/// `decl`, modulo the `mode` state-projection rewrite? Under-flags by design — any
2078/// construct not modelled here compares unequal, so a valid test is never blocked.
2079fn history_pred_matches(body: &Expr, s: &str, decl: &Expr, mode: HistoryRestate) -> bool {
2080    // Leaf equivalences the rewrite establishes.
2081    match mode {
2082        HistoryRestate::Invariant => {
2083            if let (Some(f), ExprKind::Ident(id)) = (as_new_field(body, s), &decl.kind) {
2084                return f == id.name;
2085            }
2086        }
2087        HistoryRestate::Transition => {
2088            if let (Some(root), ExprKind::Ident(id)) = (as_step_root(body, s), &decl.kind) {
2089                return root == id.name;
2090            }
2091        }
2092    }
2093    match (&body.kind, &decl.kind) {
2094        (ExprKind::Paren(x), _) => history_pred_matches(x, s, decl, mode),
2095        (_, ExprKind::Paren(y)) => history_pred_matches(body, s, y, mode),
2096        (ExprKind::IntLit { value: x, .. }, ExprKind::IntLit { value: y, .. }) => x == y,
2097        (ExprKind::BoolLit(x), ExprKind::BoolLit(y)) => x == y,
2098        (ExprKind::StrLit(x), ExprKind::StrLit(y)) => x == y,
2099        (ExprKind::Ident(x), ExprKind::Ident(y)) => x.name == y.name,
2100        (ExprKind::None, ExprKind::None) => true,
2101        (ExprKind::Some(x), ExprKind::Some(y)) => history_pred_matches(x, s, y, mode),
2102        (ExprKind::UnaryOp(o1, x), ExprKind::UnaryOp(o2, y)) => {
2103            o1 == o2 && history_pred_matches(x, s, y, mode)
2104        }
2105        (ExprKind::BinOp(o1, l1, r1), ExprKind::BinOp(o2, l2, r2)) => {
2106            o1 == o2
2107                && history_pred_matches(l1, s, l2, mode)
2108                && history_pred_matches(r1, s, r2, mode)
2109        }
2110        (
2111            ExprKind::FieldAccess {
2112                receiver: r1,
2113                field: f1,
2114            },
2115            ExprKind::FieldAccess {
2116                receiver: r2,
2117                field: f2,
2118            },
2119        ) => f1.name == f2.name && history_pred_matches(r1, s, r2, mode),
2120        (
2121            ExprKind::MethodCall {
2122                receiver: r1,
2123                method: m1,
2124                args: a1,
2125                ..
2126            },
2127            ExprKind::MethodCall {
2128                receiver: r2,
2129                method: m2,
2130                args: a2,
2131                ..
2132            },
2133        ) => {
2134            m1.name == m2.name
2135                && a1.len() == a2.len()
2136                && history_pred_matches(r1, s, r2, mode)
2137                && a1
2138                    .iter()
2139                    .zip(a2)
2140                    .all(|(x, y)| history_pred_matches(x, s, y, mode))
2141        }
2142        (
2143            ExprKind::Call {
2144                name: n1, args: a1, ..
2145            },
2146            ExprKind::Call {
2147                name: n2, args: a2, ..
2148            },
2149        ) => {
2150            n1.name == n2.name
2151                && a1.len() == a2.len()
2152                && a1
2153                    .iter()
2154                    .zip(a2)
2155                    .all(|(x, y)| history_pred_matches(x, s, y, mode))
2156        }
2157        _ => false,
2158    }
2159}
2160
2161/// v0.119 (DECISION D): a history property that merely restates a snapshot/step
2162/// invariant is redundant — the driver only commits states the invariants already
2163/// admit. Recognise the canonical shape `for all run: History[A] { expect
2164/// run.all((s) => P) }` (or `.any`) whose `P` α-matches a declared
2165/// `invariant` (over `s.new`) or `transition` (over `s.old`/`s.new`). Returns the
2166/// body span to flag. Conservative — near-duplicates slip through by design.
2167fn history_restates_invariant(prop: &PropertyDecl, run_var: &str, agent: &AgentDecl) -> bool {
2168    let [stmt] = prop.forall.body.statements.as_slice() else {
2169        return false;
2170    };
2171    let Statement::Expect(e) = stmt else {
2172        return false;
2173    };
2174    // `run.all((s) => P)` / `run.any((s) => P)`.
2175    let ExprKind::MethodCall {
2176        receiver,
2177        method,
2178        args,
2179        ..
2180    } = &e.value.kind
2181    else {
2182        return false;
2183    };
2184    if method.name != "all" && method.name != "any" {
2185        return false;
2186    }
2187    let ExprKind::Ident(recv) = &receiver.kind else {
2188        return false;
2189    };
2190    if recv.name != run_var {
2191        return false;
2192    }
2193    let [arg] = args.as_slice() else {
2194        return false;
2195    };
2196    let ExprKind::Lambda(lam) = &arg.kind else {
2197        return false;
2198    };
2199    let [param] = lam.params.as_slice() else {
2200        return false;
2201    };
2202    let s = &param.name.name;
2203    agent
2204        .invariants
2205        .iter()
2206        .any(|inv| history_pred_matches(&lam.body, s, &inv.predicate, HistoryRestate::Invariant))
2207        || agent
2208            .transitions
2209            .iter()
2210            .any(|tr| history_pred_matches(&lam.body, s, &tr.predicate, HistoryRestate::Transition))
2211}
2212
2213/// v0.119 (ADR 0155): the synthetic type names a `History[Agent]` binding
2214/// registers — a call sum, a step record, and a state record — all keyed off the
2215/// agent name so distinct agents never collide.
2216fn history_call_type_name(agent: &str) -> String {
2217    format!("__History_{agent}_Call")
2218}
2219fn history_step_type_name(agent: &str) -> String {
2220    format!("__History_{agent}_Step")
2221}
2222fn history_state_type_name(agent: &str) -> String {
2223    format!("__History_{agent}_State")
2224}
2225
2226/// The `.call` variant tag for a handler: the handler name with its first letter
2227/// upper-cased (`spend` → `Spend`, `topUp` → `TopUp`). The reader matches this
2228/// with `is` / `match` (`s.call is Spend`).
2229pub fn history_variant_name(handler: &str) -> String {
2230    let mut chars = handler.chars();
2231    match chars.next() {
2232        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2233        None => handler.to_string(),
2234    }
2235}
2236
2237/// The agent's drivable `on call` handlers — the ones a history sequences. Other
2238/// handler kinds (`http`/`cron`/`message`/`open`/`close`) are not RPC entry points
2239/// and are never part of a generated call-history.
2240pub fn history_handlers(agent: &AgentDecl) -> Vec<&Handler> {
2241    agent
2242        .handlers
2243        .iter()
2244        .filter(|h| matches!(h.kind, HandlerKind::Call) && h.method_name.is_some())
2245        .collect()
2246}
2247
2248/// v0.119 (testing track slice 7, ADR 0155): type-check a `for all run:
2249/// History[Agent]` binding. The subject is a *run* of the agent — a generated,
2250/// driven call-history — bound as an ordinary `List[Step]`. Validates the
2251/// DECISION-B rules (agent-only, every handler parameter generable), registers the
2252/// synthetic call-sum / step / state record types into `resolved.types` so the
2253/// predicate's `List` + value surface (`.call is …`, `.old`/`.new`, `.accepted`)
2254/// type-checks, and returns the bound `List[Step]` type.
2255pub fn check_history_binding(
2256    inner: &TypeRef,
2257    span: Span,
2258    resolved: &mut ResolvedCommons,
2259    refs: &mut RefSink,
2260    tys: &Arc<Types>,
2261) -> Result<checker::Ty, CompileError> {
2262    // DECISION B: only an agent has handlers to sequence and reachable states to
2263    // observe. `History[Value]` / `History[List[…]]` is `not_an_agent`.
2264    let TypeRef::Named(agent_id) = inner else {
2265        return Err(CompileError::new(
2266            "bynk.history.not_an_agent",
2267            span,
2268            format!(
2269                "`for all` cannot generate `History[{}]` — only an agent has handlers to sequence",
2270                ts_type_ref_display(inner)
2271            ),
2272        )
2273        .with_note("generate a driven call-history over an agent: `for all run: History[Agent]`"));
2274    };
2275    let Some(agent) = resolved.agents.get(&agent_id.name).cloned() else {
2276        return Err(CompileError::new(
2277            "bynk.history.not_an_agent",
2278            span,
2279            format!(
2280                "`for all run: History[{}]` names `{}`, which is not an agent in scope",
2281                agent_id.name, agent_id.name
2282            ),
2283        )
2284        .with_note(
2285            "only an agent (with handlers and reachable state) can be driven as a history",
2286        ));
2287    };
2288    refs.record(agent_id.span, SymbolKind::Type, &agent_id.name);
2289
2290    let handlers = history_handlers(&agent);
2291    // DECISION B: the agent must be *drivable* — every handler parameter must be
2292    // refinement-generable (the same rule a value `for all` binding obeys), else
2293    // the runner cannot synthesise a call.
2294    for h in &handlers {
2295        for p in &h.params {
2296            let generable = checker::resolve_type_ref(&p.type_ref, &resolved.types, tys)
2297                .is_some_and(|t| prop_binding_generable(t, &resolved.types, PROP_GEN_DEPTH, tys));
2298            if !generable {
2299                return Err(CompileError::new(
2300                    "bynk.history.not_generable",
2301                    span,
2302                    format!(
2303                        "`History[{}]` cannot be driven — handler `{}`'s parameter `{}: {}` is not generable (e.g. a `Matches` refinement)",
2304                        agent_id.name,
2305                        h.method_name.as_ref().map(|m| m.name.as_str()).unwrap_or(""),
2306                        p.name.name,
2307                        ts_type_ref_display(&p.type_ref),
2308                    ),
2309                )
2310                .with_note(
2311                    "every handler parameter must be refinement-generable for the run to be seeded",
2312                ));
2313            }
2314        }
2315    }
2316
2317    // Register the synthetic types (mirrors `register_call_record_types`). The
2318    // driver returns plain objects of exactly these shapes; the checker sees them
2319    // as ordinary record/sum types so `is`, field access, and `implies` apply
2320    // unchanged (the typed-step shape resolving the track's open question).
2321    let state_name = history_state_type_name(&agent_id.name);
2322    let call_name = history_call_type_name(&agent_id.name);
2323    let step_name = history_step_type_name(&agent_id.name);
2324
2325    // `<Agent>State` — the agent's `Cell` fields, exactly as the emitted state
2326    // record (so `.old.balance` / `.new.balance` read a reached state).
2327    let state_fields: Vec<RecordField> = agent
2328        .store_fields
2329        .iter()
2330        .filter(|f| f.kind.head.name == "Cell" && f.kind.args.len() == 1)
2331        .map(|f| RecordField {
2332            trivia: Default::default(),
2333            name: f.name.clone(),
2334            type_ref: f.kind.args[0].clone(),
2335            refinement: None,
2336            init: None,
2337            span: f.span,
2338        })
2339        .collect();
2340    resolved.types.insert(
2341        state_name.clone(),
2342        Arc::new(TypeDecl {
2343            type_params: Vec::new(),
2344            name: Ident {
2345                name: state_name.clone(),
2346                span,
2347            },
2348            body: TypeBody::Record(RecordBody {
2349                trailing_comments: Default::default(),
2350                fields: state_fields,
2351                span,
2352            }),
2353            documentation: None,
2354            span,
2355            trivia: Trivia::default(),
2356        }),
2357    );
2358
2359    // `.call` — a sum over the agent's handlers, each variant carrying the
2360    // handler's generated arguments (`Spend { amount }`, `TopUp { amount }`).
2361    let variants: Vec<Variant> = handlers
2362        .iter()
2363        .map(|h| {
2364            let hname = h.method_name.as_ref().expect("call handler has a name");
2365            Variant {
2366                trivia: Default::default(),
2367                name: Ident {
2368                    name: history_variant_name(&hname.name),
2369                    span: hname.span,
2370                },
2371                payload: h
2372                    .params
2373                    .iter()
2374                    .map(|p| VariantField {
2375                        name: p.name.clone(),
2376                        type_ref: p.type_ref.clone(),
2377                        span: p.span,
2378                    })
2379                    .collect(),
2380                span: hname.span,
2381            }
2382        })
2383        .collect();
2384    resolved.types.insert(
2385        call_name.clone(),
2386        Arc::new(TypeDecl {
2387            type_params: Vec::new(),
2388            name: Ident {
2389                name: call_name.clone(),
2390                span,
2391            },
2392            body: TypeBody::Sum(SumBody {
2393                trailing_comments: Default::default(),
2394                variants,
2395                embeds: Vec::new(),
2396                span,
2397            }),
2398            documentation: None,
2399            span,
2400            trivia: Trivia::default(),
2401        }),
2402    );
2403
2404    // A `Step` — the driven edge: which call ran (`.call`), whether it committed
2405    // (`.accepted`), and the committed `old` → `new` state pair.
2406    let step_fields = vec![
2407        RecordField {
2408            trivia: Default::default(),
2409            name: Ident {
2410                name: "call".to_string(),
2411                span,
2412            },
2413            type_ref: TypeRef::Named(Ident {
2414                name: call_name.clone(),
2415                span,
2416            }),
2417            refinement: None,
2418            init: None,
2419            span,
2420        },
2421        RecordField {
2422            trivia: Default::default(),
2423            name: Ident {
2424                name: "accepted".to_string(),
2425                span,
2426            },
2427            type_ref: TypeRef::Base(BaseType::Bool, span),
2428            refinement: None,
2429            init: None,
2430            span,
2431        },
2432        RecordField {
2433            trivia: Default::default(),
2434            name: Ident {
2435                name: "old".to_string(),
2436                span,
2437            },
2438            type_ref: TypeRef::Named(Ident {
2439                name: state_name.clone(),
2440                span,
2441            }),
2442            refinement: None,
2443            init: None,
2444            span,
2445        },
2446        RecordField {
2447            trivia: Default::default(),
2448            name: Ident {
2449                name: "new".to_string(),
2450                span,
2451            },
2452            type_ref: TypeRef::Named(Ident {
2453                name: state_name.clone(),
2454                span,
2455            }),
2456            refinement: None,
2457            init: None,
2458            span,
2459        },
2460    ];
2461    resolved.types.insert(
2462        step_name.clone(),
2463        Arc::new(TypeDecl {
2464            type_params: Vec::new(),
2465            name: Ident {
2466                name: step_name.clone(),
2467                span,
2468            },
2469            body: TypeBody::Record(RecordBody {
2470                trailing_comments: Default::default(),
2471                fields: step_fields,
2472                span,
2473            }),
2474            documentation: None,
2475            span,
2476            trivia: Trivia::default(),
2477        }),
2478    );
2479
2480    Ok(checker::Ty::List(tys.intern(checker::Ty::Named {
2481        name: step_name,
2482        kind: checker::NamedKind::Record,
2483        args: Vec::new(),
2484    })))
2485}
2486
2487/// v0.114: type-check a generative `property` — its `for all` bindings, the
2488/// optional `where` filter, and the predicate body — in the target's privileged
2489/// view. Bindings type each `x: T`; `where`/`expect` predicates type as pure
2490/// `Bool`; each binding's `T` must be refinement-generable (agents are rejected;
2491/// a `Matches` type must pin); and the body is flagged if it merely restates a
2492/// refinement (DECISION P). v0.119: a `for all run: History[Agent]` binding is a
2493/// driven call-history (the history rung — see [`check_history_binding`]).
2494#[allow(clippy::too_many_arguments)]
2495fn check_property_body(
2496    target_name: &str,
2497    target_kind: UnitKind,
2498    prop: &PropertyDecl,
2499    unit_tables: &HashMap<String, UnitTable>,
2500    unit_uses: &HashMap<String, Vec<String>>,
2501    unit_consumes: &HashMap<String, Vec<String>>,
2502    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2503    errors: &mut Vec<CompileError>,
2504    refs: &mut RefSink,
2505    tys: &Arc<Types>,
2506) {
2507    let Some((mut resolved, _)) = build_privileged_resolved(
2508        target_name,
2509        unit_tables,
2510        unit_uses,
2511        unit_consumes,
2512        unit_consumes_aliases,
2513    ) else {
2514        return;
2515    };
2516    register_call_record_types(&mut resolved, target_name, unit_tables);
2517    let _ = target_kind;
2518
2519    // Bind each `for all x: T` into the predicate scope, checking generability.
2520    let mut binding_scope: HashMap<String, checker::TyId> = HashMap::new();
2521    let mut binding_types: Vec<(String, Option<checker::TyId>)> = Vec::new();
2522    // v0.119: the single `History[Agent]` binding (run-var, agent), for the
2523    // post-body `restates_invariant` check (DECISION D).
2524    let mut history_binding: Option<(String, AgentDecl)> = None;
2525    for b in &prop.forall.bindings {
2526        // v0.119 (ADR 0155): `for all run: History[Agent]` — the history rung. A
2527        // driven call-history, bound as an ordinary `List[Step]`.
2528        if let TypeRef::History(inner, hspan) = &b.type_ref {
2529            match check_history_binding(inner, *hspan, &mut resolved, refs, tys) {
2530                Ok(step_ty) => {
2531                    if let TypeRef::Named(agent_id) = &**inner
2532                        && let Some(agent) = resolved.agents.get(&agent_id.name)
2533                    {
2534                        history_binding = Some((b.name.name.clone(), agent.clone()));
2535                    }
2536                    binding_scope.insert(b.name.name.clone(), tys.intern(step_ty.clone()));
2537                    binding_types.push((b.name.name.clone(), Some(tys.intern(step_ty))));
2538                }
2539                Err(err) => {
2540                    errors.push(err);
2541                    binding_types.push((b.name.name.clone(), None));
2542                    // #1708: rejected, but still in scope (error-typed), so
2543                    // its uses in the body do not echo as unknown names.
2544                    binding_scope.insert(b.name.name.clone(), tys.intern(checker::Ty::Error));
2545                }
2546            }
2547            continue;
2548        }
2549        // Agents are not a value type — a fabricated state that satisfies every
2550        // invariant need not be reachable (DECISION P); reject up front.
2551        if let TypeRef::Named(id) = &b.type_ref
2552            && resolved.agents.contains_key(&id.name)
2553        {
2554            errors.push(
2555                CompileError::new(
2556                    "bynk.val.agent_not_generable",
2557                    b.type_ref.span(),
2558                    format!(
2559                        "`for all {}: {}` cannot generate an agent — a fabricated agent state need not be reachable",
2560                        b.name.name, id.name
2561                    ),
2562                )
2563                .with_note(
2564                    "generate behaviour over an agent via handler sequences (the history rung), not fabricated states",
2565                ),
2566            );
2567            binding_types.push((b.name.name.clone(), None));
2568            // #1708: rejected, but still in scope (error-typed), so
2569            // its uses in the body do not echo as unknown names.
2570            binding_scope.insert(b.name.name.clone(), tys.intern(checker::Ty::Error));
2571            continue;
2572        }
2573        let ty = match checker::resolve_type_ref(&b.type_ref, &resolved.types, tys) {
2574            Some(t) => {
2575                record_type_refs_in_property(&b.type_ref, &resolved, refs);
2576                t
2577            }
2578            None => {
2579                errors.push(CompileError::new(
2580                    "bynk.val.unknown_type",
2581                    b.type_ref.span(),
2582                    format!(
2583                        "`for all {}: {}` names a type that does not resolve",
2584                        b.name.name,
2585                        ts_type_ref_display(&b.type_ref)
2586                    ),
2587                ));
2588                binding_types.push((b.name.name.clone(), None));
2589                // #1708: rejected, but still in scope (error-typed), so
2590                // its uses in the body do not echo as unknown names.
2591                binding_scope.insert(b.name.name.clone(), tys.intern(checker::Ty::Error));
2592                continue;
2593            }
2594        };
2595        if !prop_binding_generable(ty, &resolved.types, PROP_GEN_DEPTH, tys) {
2596            errors.push(
2597                CompileError::new(
2598                    "bynk.val.needs_pin",
2599                    b.type_ref.span(),
2600                    format!(
2601                        "`for all {}: {}` cannot generate a value (e.g. a `Matches` refinement); a property cannot bind it",
2602                        b.name.name,
2603                        ts_type_ref_display(&b.type_ref)
2604                    ),
2605                )
2606                .with_note("supply the witness in a `case` with a pinned `Val[T](...)` instead"),
2607            );
2608        }
2609        binding_scope.insert(b.name.name.clone(), ty);
2610        binding_types.push((b.name.name.clone(), Some(ty)));
2611    }
2612
2613    // Type the `where`/body predicates in the target's privileged view with the
2614    // bindings in scope — mirroring the `case` body context.
2615    let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
2616    let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
2617    let unit_span = prop.span;
2618    let synthetic_return = TypeRef::Effect(
2619        Box::new(TypeRef::Result(
2620            Box::new(TypeRef::Unit(unit_span)),
2621            Box::new(TypeRef::ValidationError(unit_span)),
2622            unit_span,
2623        )),
2624        unit_span,
2625    );
2626    let mut capability_info_map: HashMap<String, checker::CapabilityInfo> = HashMap::new();
2627    if let Some(table) = unit_tables.get(target_name) {
2628        for (name, decl) in &table.capabilities {
2629            let ops = decl
2630                .ops
2631                .iter()
2632                .map(|op| build_capability_op_info(op, &resolved.types, tys))
2633                .collect();
2634            capability_info_map.insert(
2635                name.clone(),
2636                checker::CapabilityInfo {
2637                    name: name.clone(),
2638                    ops,
2639                },
2640            );
2641        }
2642    }
2643    add_consumed_adapter_capabilities(&mut capability_info_map, unit_tables, &resolved, tys);
2644    let given_declared: Vec<String> = capability_info_map.keys().cloned().collect();
2645    let return_ty = checker::resolve_type_ref(&synthetic_return, &resolved.types, tys).unwrap();
2646    let return_ty_span = prop.span;
2647    let mut no_hints = HintSink::new();
2648    let mut no_locals = LocalsSink::new();
2649    let mut no_requirements = RequirementSink::new();
2650    // The optional `where` filter is checked first (against `Bool`), sharing
2651    // `check_body`'s `Ctx` with the body below; the body is the one predicate
2652    // surface: `expect`s self-check as `Bool`.
2653    let _ = checker::check_body(
2654        &resolved,
2655        &prop.forall.body,
2656        return_ty,
2657        return_ty_span,
2658        binding_scope,
2659        checker::CapabilityCtx {
2660            capabilities: capability_info_map.clone(),
2661            declared_capabilities: capability_info_map,
2662            given_remaining: given_declared.iter().cloned().collect(),
2663            given_used: HashSet::new(),
2664            given_entries: Vec::new(),
2665            given_anchor: None,
2666        },
2667        target_test_services(unit_tables.get(target_name)),
2668        target_test_actors(unit_tables.get(target_name)),
2669        prop.forall.where_pred.as_ref(),
2670        checker::CheckSinks {
2671            tys,
2672            expr_types: &mut expr_types,
2673            errors,
2674            refs,
2675            hints: &mut no_hints,
2676            locals: &mut no_locals,
2677            requirements: &mut no_requirements,
2678            callees: &mut callees,
2679        },
2680    );
2681
2682    // Conservative restates-refinement flag: a single-binding property whose
2683    // body is exactly `expect <pred>` restating the bound var's refinement.
2684    if let [(var, Some(ty))] = binding_types.as_slice()
2685        && let Some(refinement) = named_refinement(*ty, &resolved.types, tys)
2686        && let [stmt] = prop.forall.body.statements.as_slice()
2687        && let Statement::Expect(e) = stmt
2688        && predicate_restates_refinement(&e.value, var, refinement)
2689    {
2690        errors.push(
2691            CompileError::new(
2692                "bynk.property.restates_refinement",
2693                prop.forall.body.span,
2694                format!(
2695                    "property `{}` merely re-checks a refinement type `{}` already guarantees",
2696                    prop.name,
2697                    ty.display(tys)
2698                ),
2699            )
2700            .with_note(
2701                "a property earns its keep by asserting behaviour over valid inputs, not by restating the type's refinement",
2702            ),
2703        );
2704    }
2705
2706    // v0.119 (DECISION D): a history property that merely restates a declared
2707    // `invariant` / `transition` re-checks a guarantee every reached state already
2708    // has (the driver only commits admissible states). Conservative — near-
2709    // duplicates slip through by design.
2710    if let Some((run_var, agent)) = &history_binding
2711        && history_restates_invariant(prop, run_var, agent)
2712    {
2713        errors.push(
2714            CompileError::new(
2715                "bynk.history.restates_invariant",
2716                prop.forall.body.span,
2717                format!(
2718                    "history property `{}` merely re-checks a guarantee agent `{}`'s `invariant`/`transition` already enforces on every reached state",
2719                    prop.name, agent.name.name
2720                ),
2721            )
2722            .with_note(
2723                "a history property earns its keep by asserting a cross-step protocol, not by restating a per-state invariant",
2724            ),
2725        );
2726    }
2727}
2728
2729/// Record type references named by a `for all` binding so cross-file edges and
2730/// go-to-definition resolve for a property's generated types.
2731fn record_type_refs_in_property(
2732    type_ref: &TypeRef,
2733    resolved: &ResolvedCommons,
2734    refs: &mut RefSink,
2735) {
2736    checker::record_type_refs(type_ref, &resolved.types, &HashSet::new(), refs);
2737}
2738
2739/// Build a [`resolver::ResolvedCommons`] backed by `owning_unit`'s privileged
2740/// view: its types, fns, methods, plus types/fns from every commons it
2741/// `uses`, plus exported types from every consumed context. The same
2742/// shape used by the production pipeline. Returns the [`ResolvedCommons`]
2743/// plus a synthetic commons span for the test.
2744pub fn build_privileged_resolved(
2745    owning_unit: &str,
2746    unit_tables: &HashMap<String, UnitTable>,
2747    unit_uses: &HashMap<String, Vec<String>>,
2748    unit_consumes: &HashMap<String, Vec<String>>,
2749    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2750) -> Option<(ResolvedCommons, ())> {
2751    let local = unit_tables.get(owning_unit)?;
2752    let mut types = local.types.clone();
2753    let mut fns = local.fns.clone();
2754    let mut methods = local.methods.clone();
2755    if let Some(targets) = unit_uses.get(owning_unit) {
2756        for t in targets {
2757            if let Some(used) = unit_tables.get(t) {
2758                for (n, d) in &used.types {
2759                    types.entry(n.clone()).or_insert_with(|| d.clone());
2760                }
2761                for (n, d) in &used.fns {
2762                    fns.entry(n.clone()).or_insert_with(|| d.clone());
2763                }
2764                for (n, mt) in &used.methods {
2765                    let entry = methods.entry(n.clone()).or_default();
2766                    for (m, decl) in &mt.instance {
2767                        entry
2768                            .instance
2769                            .entry(m.clone())
2770                            .or_insert_with(|| decl.clone());
2771                    }
2772                    for (m, decl) in &mt.statics {
2773                        entry
2774                            .statics
2775                            .entry(m.clone())
2776                            .or_insert_with(|| decl.clone());
2777                    }
2778                }
2779            }
2780        }
2781    }
2782    // Consumed-context types come in too (only the exported ones).
2783    if let Some(consumed) = unit_consumes.get(owning_unit) {
2784        for t in consumed {
2785            if let Some(used) = unit_tables.get(t) {
2786                for (n, d) in &used.types {
2787                    types.entry(n.clone()).or_insert_with(|| d.clone());
2788                }
2789                for (n, mt) in &used.methods {
2790                    let entry = methods.entry(n.clone()).or_default();
2791                    for (m, decl) in &mt.instance {
2792                        entry
2793                            .instance
2794                            .entry(m.clone())
2795                            .or_insert_with(|| decl.clone());
2796                    }
2797                }
2798            }
2799        }
2800    }
2801    let cross_context = build_cross_context_info(
2802        owning_unit,
2803        unit_consumes,
2804        unit_consumes_aliases,
2805        unit_uses,
2806        unit_tables,
2807    );
2808    let synthetic_commons = Commons {
2809        name: QualifiedName {
2810            parts: owning_unit
2811                .split('.')
2812                .map(|part| Ident {
2813                    name: part.to_string(),
2814                    span: Span::default(),
2815                })
2816                .collect(),
2817            span: Span::default(),
2818        },
2819        items: Vec::new(),
2820        uses: Vec::new(),
2821        documentation: None,
2822        form: CommonsForm::Brace,
2823        span: Span::default(),
2824        trivia: Trivia::default(),
2825        trailing_comments: Vec::new(),
2826    };
2827    let agents_for_resolved = unit_tables
2828        .get(owning_unit)
2829        .map(|t| t.agents.clone())
2830        .unwrap_or_default();
2831    let no_local_events = HashMap::new();
2832    let resolved = ResolvedCommons::new(
2833        synthetic_commons,
2834        types,
2835        &local.types,
2836        fns,
2837        methods,
2838        agents_for_resolved,
2839        // "Privileged" test/stub-body resolved — deliberately relaxed, not a
2840        // real context emission subject to the rebrand — so events stay
2841        // empty rather than reading `local`'s.
2842        &no_local_events,
2843        cross_context,
2844        HashMap::new(),
2845        false,
2846        HashSet::new(),
2847    );
2848    Some((resolved, ()))
2849}
2850
2851#[cfg(test)]
2852mod tests {
2853    use super::*;
2854
2855    /// The body of `service svc`'s single handler in a parsed context.
2856    fn handler_body(src: &str) -> Block {
2857        let tokens = bynk_syntax::lexer::tokenize(src).expect("lex");
2858        let unit = bynk_syntax::parser::parse_unit(&tokens, src).expect("parse");
2859        let SourceUnit::Context(ctx) = unit else {
2860            panic!("expected a context unit");
2861        };
2862        ctx.items
2863            .into_iter()
2864            .find_map(|item| match item {
2865                bynk_syntax::ast::CommonsItem::Service(s) => s.handlers.into_iter().next(),
2866                _ => None,
2867            })
2868            .expect("a service handler")
2869            .body
2870    }
2871
2872    const SRC: &str = "context demo\n\
2873        service svc {\n\
2874          on call(n: Int) -> Effect[Int] {\n\
2875            let a <- first(n)\n\
2876            let b = if n > 0 { let c = second(n)\n c } else { third(n) }\n\
2877            fourth(a + b)\n\
2878          }\n\
2879        }\n";
2880
2881    /// #1740 review: the walk is in source order (it used to run bottom-up), so
2882    /// "the first cross-context call" and the order errors are reported in
2883    /// both read top to bottom.
2884    #[test]
2885    fn exprs_in_order_is_source_order() {
2886        let body = handler_body(SRC);
2887        let calls: Vec<&str> = exprs_in_order(&body)
2888            .into_iter()
2889            .filter_map(|e| match &e.kind {
2890                ExprKind::Call { name, .. } => Some(name.name.as_str()),
2891                _ => None,
2892            })
2893            .collect();
2894        assert_eq!(calls, ["first", "second", "third", "fourth"]);
2895    }
2896
2897    /// `blocks_deep` reaches a block nested in an `if` branch, so a `let` there
2898    /// (an agent binding, or a name that shadows a service) is seen.
2899    #[test]
2900    fn blocks_deep_reaches_nested_lets() {
2901        let body = handler_body(SRC);
2902        let lets: Vec<String> = blocks_deep(&body)
2903            .iter()
2904            .flat_map(|b| &b.statements)
2905            .filter_map(|s| match s {
2906                Statement::Let(l) | Statement::EffectLet(l) => Some(l.name.name.clone()),
2907                _ => None,
2908            })
2909            .collect();
2910        assert!(lets.contains(&"c".to_string()), "{lets:?}");
2911        assert!(lets.contains(&"a".to_string()) && lets.contains(&"b".to_string()));
2912    }
2913}