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