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. The **check** path feeds real
1076/// diagnostic/ref sinks; the **emit** path reuses it with throwaway sinks to give
1077/// the case-body lowering full type information (so collection kernels — notably
1078/// `trace(Cap.op)`'s `List[…]` methods — dispatch on the receiver's checked type).
1079#[allow(clippy::too_many_arguments)]
1080pub fn typecheck_case_body(
1081    target_name: &str,
1082    body: &Block,
1083    unit_span: Span,
1084    unit_tables: &HashMap<String, UnitTable>,
1085    resolved: &ResolvedCommons,
1086    errors: &mut Vec<CompileError>,
1087    refs: &mut RefSink,
1088    // v0.119: bindings already in scope for the body — empty for a `case`, the
1089    // `run: List[Step]` binding for a history property.
1090    initial_scope: HashMap<String, checker::TyId>,
1091    tys: &Arc<Types>,
1092) -> HashMap<ExprId, checker::TypedExpr> {
1093    let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
1094    let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
1095    // Synthesise an Effect[Result[(), ValidationError]] return type as a
1096    // stand-in for Effect[Result[(), ExpectationError]]. v0.7 doesn't model an
1097    // explicit ExpectationError type — the runtime catches it instead.
1098    let synthetic_return = TypeRef::Effect(
1099        Box::new(TypeRef::Result(
1100            Box::new(TypeRef::Unit(unit_span)),
1101            Box::new(TypeRef::ValidationError(unit_span)),
1102            unit_span,
1103        )),
1104        unit_span,
1105    );
1106
1107    // Capabilities of the target context, if any (so the test body can
1108    // call capabilities directly when targeting a context).
1109    let mut capability_info_map: HashMap<String, checker::CapabilityInfo> = HashMap::new();
1110    if let Some(table) = unit_tables.get(target_name) {
1111        for (name, decl) in &table.capabilities {
1112            let ops = decl
1113                .ops
1114                .iter()
1115                .map(|op| build_capability_op_info(op, &resolved.types, tys))
1116                .collect();
1117            capability_info_map.insert(
1118                name.clone(),
1119                checker::CapabilityInfo {
1120                    name: name.clone(),
1121                    ops,
1122                },
1123            );
1124        }
1125    }
1126
1127    // All declared capabilities are implicitly "given" inside a test body;
1128    // the test runner wires them via the mocked deps. We feed the same map
1129    // to both `capabilities` (in-scope) and `declared_capabilities`.
1130    let given_declared: Vec<String> = capability_info_map.keys().cloned().collect();
1131
1132    let return_ty = checker::resolve_type_ref(&synthetic_return, &resolved.types, tys).unwrap();
1133    let return_ty_span = unit_span;
1134    // Test bodies record no hints (out of v0.27 scope) — a throwaway sink.
1135    let mut no_hints = HintSink::new();
1136    let mut no_locals = LocalsSink::new();
1137    // Test bodies record no capability requirements either — muted sink.
1138    let mut no_requirements = RequirementSink::new();
1139    let _ = checker::check_body(
1140        resolved,
1141        body,
1142        return_ty,
1143        return_ty_span,
1144        initial_scope,
1145        checker::CapabilityCtx {
1146            capabilities: capability_info_map.clone(),
1147            declared_capabilities: capability_info_map,
1148            given_remaining: given_declared.iter().cloned().collect(),
1149            given_used: HashSet::new(),
1150            given_entries: Vec::new(),
1151            given_anchor: None,
1152        },
1153        target_test_services(unit_tables.get(target_name)),
1154        target_test_actors(unit_tables.get(target_name)),
1155        None,
1156        checker::CheckSinks {
1157            tys,
1158            expr_types: &mut expr_types,
1159            errors,
1160            refs,
1161            hints: &mut no_hints,
1162            locals: &mut no_locals,
1163            requirements: &mut no_requirements,
1164            callees: &mut callees,
1165        },
1166    );
1167    expr_types
1168}
1169
1170#[allow(clippy::too_many_arguments)]
1171fn check_test_case_body(
1172    target_name: &str,
1173    target_kind: UnitKind,
1174    case: &Case,
1175    unit_tables: &HashMap<String, UnitTable>,
1176    unit_uses: &HashMap<String, Vec<String>>,
1177    unit_consumes: &HashMap<String, Vec<String>>,
1178    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
1179    errors: &mut Vec<CompileError>,
1180    refs: &mut RefSink,
1181    tys: &Arc<Types>,
1182) {
1183    let Some((mut resolved, _)) = build_privileged_resolved(
1184        target_name,
1185        unit_tables,
1186        unit_uses,
1187        unit_consumes,
1188        unit_consumes_aliases,
1189    ) else {
1190        return;
1191    };
1192    register_call_record_types(&mut resolved, target_name, unit_tables);
1193    let _ = target_kind;
1194    let _ = typecheck_case_body(
1195        target_name,
1196        &case.body,
1197        case.span,
1198        unit_tables,
1199        &resolved,
1200        errors,
1201        refs,
1202        HashMap::new(),
1203        tys,
1204    );
1205    // Don't enforce return-type equality; the test runner discards the
1206    // tail expression and recovers success/failure from expectation outcome.
1207    // Don't enforce "every given used" — capabilities are implicitly
1208    // available in a test body.
1209
1210    // v0.115: flag a `case` that merely restates a contract already declared at
1211    // the source (`bynk.contract.restated_by_test`) — an `expect` that is
1212    // α-equivalent to an `ensures` clause over the same bound arguments. The dev
1213    // guard and the runner attack already check it. Conservative: under-flagging
1214    // is acceptable, over-flagging is not.
1215    check_restated_contract(&case.body, &resolved, errors);
1216}
1217
1218/// v0.115: within a test body, flag an `expect` that re-states a contract's
1219/// `ensures`. Fires only on the clearest restatement: a binding `let r = f(args)`
1220/// (or `r <- f(args)`) of a contracted free function's result, followed by an
1221/// `expect E` that is α-equivalent to one of `f`'s `ensures` predicates under the
1222/// substitution `result → r`, `params → args`. Syntactic — never semantic — so a
1223/// merely-equivalent (but differently written) test is not flagged.
1224fn check_restated_contract(
1225    body: &Block,
1226    resolved: &ResolvedCommons,
1227    errors: &mut Vec<CompileError>,
1228) {
1229    // Map each locally-bound name to the contracted free function + call args it
1230    // was bound from (`let r = f(a, b)`).
1231    let mut bound: HashMap<String, (&FnDecl, &[Expr])> = HashMap::new();
1232    for stmt in &body.statements {
1233        let (name, value) = match stmt {
1234            Statement::Let(l) | Statement::EffectLet(l) => (&l.name.name, &l.value),
1235            _ => continue,
1236        };
1237        if let ExprKind::Call {
1238            name: callee, args, ..
1239        } = &value.kind
1240            && let Some(f) = resolved.fns.get(&callee.name)
1241            && matches!(&f.name, FnName::Free(_))
1242            && !f.ensures.is_empty()
1243            && f.params.len() == args.len()
1244        {
1245            bound.insert(name.clone(), (f, args.as_slice()));
1246        }
1247    }
1248    if bound.is_empty() {
1249        return;
1250    }
1251    for stmt in &body.statements {
1252        let Statement::Expect(e) = stmt else { continue };
1253        for (result_name, (f, args)) in &bound {
1254            // subst: result → r, each param → its call argument.
1255            let result_ident = Expr {
1256                id: ExprId::SYNTHETIC,
1257                kind: ExprKind::Ident(Ident {
1258                    name: result_name.clone(),
1259                    span: e.span,
1260                }),
1261                span: e.span,
1262            };
1263            let mut subst: HashMap<&str, &Expr> = HashMap::new();
1264            subst.insert("result", &result_ident);
1265            for (p, a) in f.params.iter().zip(args.iter()) {
1266                subst.insert(p.name.name.as_str(), a);
1267            }
1268            for c in &f.ensures {
1269                if expr_alpha_eq_subst(&c.predicate, &e.value, &subst) {
1270                    let FnName::Free(fname) = &f.name else {
1271                        continue;
1272                    };
1273                    errors.push(
1274                        CompileError::new(
1275                            "bynk.contract.restated_by_test",
1276                            e.span,
1277                            format!(
1278                                "this `expect` restates the `ensures {}` contract of `{}`, which is already checked at every call and by the runner",
1279                                c.name.name, fname.name
1280                            ),
1281                        )
1282                        .with_note(
1283                            "a contract is checked everywhere for free — delete the restating test, or keep a `case` only for a specific witnessed value",
1284                        ),
1285                    );
1286                    break;
1287                }
1288            }
1289        }
1290    }
1291}
1292
1293/// Structural (α-)equality of two predicate expressions, ignoring spans, where a
1294/// bare identifier in `pattern` that appears in `subst` must match the
1295/// corresponding substituted expression in `actual` (the rest compares by shape).
1296/// Deliberately conservative — only the operators/leaves a contract predicate can
1297/// contain are compared; anything unrecognised is unequal.
1298fn expr_alpha_eq_subst(pattern: &Expr, actual: &Expr, subst: &HashMap<&str, &Expr>) -> bool {
1299    if let ExprKind::Ident(id) = &pattern.kind
1300        && let Some(replacement) = subst.get(id.name.as_str())
1301    {
1302        return expr_struct_eq(replacement, actual);
1303    }
1304    match (&pattern.kind, &actual.kind) {
1305        (ExprKind::Ident(a), ExprKind::Ident(b)) => a.name == b.name,
1306        (ExprKind::IntLit { value: a, .. }, ExprKind::IntLit { value: b, .. }) => a == b,
1307        (ExprKind::BoolLit(a), ExprKind::BoolLit(b)) => a == b,
1308        (ExprKind::StrLit(a), ExprKind::StrLit(b)) => a == b,
1309        (ExprKind::Paren(a), _) => expr_alpha_eq_subst(a, actual, subst),
1310        (_, ExprKind::Paren(b)) => expr_alpha_eq_subst(pattern, b, subst),
1311        (ExprKind::BinOp(oa, la, ra), ExprKind::BinOp(ob, lb, rb)) => {
1312            oa == ob && expr_alpha_eq_subst(la, lb, subst) && expr_alpha_eq_subst(ra, rb, subst)
1313        }
1314        (ExprKind::UnaryOp(oa, a), ExprKind::UnaryOp(ob, b)) => {
1315            oa == ob && expr_alpha_eq_subst(a, b, subst)
1316        }
1317        (
1318            ExprKind::MethodCall {
1319                receiver: ra,
1320                method: ma,
1321                args: aa,
1322                ..
1323            },
1324            ExprKind::MethodCall {
1325                receiver: rb,
1326                method: mb,
1327                args: ab,
1328                ..
1329            },
1330        ) => {
1331            ma.name == mb.name
1332                && aa.len() == ab.len()
1333                && expr_alpha_eq_subst(ra, rb, subst)
1334                && aa
1335                    .iter()
1336                    .zip(ab.iter())
1337                    .all(|(x, y)| expr_alpha_eq_subst(x, y, subst))
1338        }
1339        _ => false,
1340    }
1341}
1342
1343/// Plain structural equality of two expressions ignoring spans — used to compare
1344/// a substituted argument against its use in the test predicate.
1345fn expr_struct_eq(a: &Expr, b: &Expr) -> bool {
1346    match (&a.kind, &b.kind) {
1347        (ExprKind::Ident(x), ExprKind::Ident(y)) => x.name == y.name,
1348        (ExprKind::IntLit { value: x, .. }, ExprKind::IntLit { value: y, .. }) => x == y,
1349        (ExprKind::BoolLit(x), ExprKind::BoolLit(y)) => x == y,
1350        (ExprKind::StrLit(x), ExprKind::StrLit(y)) => x == y,
1351        (ExprKind::Paren(x), _) => expr_struct_eq(x, b),
1352        (_, ExprKind::Paren(y)) => expr_struct_eq(a, y),
1353        (ExprKind::BinOp(oa, la, ra), ExprKind::BinOp(ob, lb, rb)) => {
1354            oa == ob && expr_struct_eq(la, lb) && expr_struct_eq(ra, rb)
1355        }
1356        (ExprKind::UnaryOp(oa, x), ExprKind::UnaryOp(ob, y)) => oa == ob && expr_struct_eq(x, y),
1357        (
1358            ExprKind::MethodCall {
1359                receiver: ra,
1360                method: ma,
1361                args: aa,
1362                ..
1363            },
1364            ExprKind::MethodCall {
1365                receiver: rb,
1366                method: mb,
1367                args: ab,
1368                ..
1369            },
1370        ) => {
1371            ma.name == mb.name
1372                && aa.len() == ab.len()
1373                && expr_struct_eq(ra, rb)
1374                && aa.iter().zip(ab.iter()).all(|(x, y)| expr_struct_eq(x, y))
1375        }
1376        _ => false,
1377    }
1378}
1379
1380/// v0.114: the recursion cap for property-binding generability (mirrors the
1381/// checker's `MOCK_DEPTH` for bare `Val`).
1382pub const PROP_GEN_DEPTH: u32 = 12;
1383
1384/// Whether a `for all x: T` binding's type is refinement-generable: refined
1385/// types must not carry a `Matches` predicate (no refinement-driven generator),
1386/// and sums/records must have every component recursively generable within the
1387/// depth cap. Mirrors the checker's `can_mock_bare`.
1388pub fn prop_binding_generable(
1389    ty: checker::TyId,
1390    types: &HashMap<String, Arc<TypeDecl>>,
1391    depth: u32,
1392    tys: &Arc<Types>,
1393) -> bool {
1394    if depth == 0 {
1395        return false;
1396    }
1397    match &*tys.get(ty) {
1398        checker::Ty::Base(_) => true,
1399        checker::Ty::Named { name, .. } => {
1400            let Some(decl) = types.get(name) else {
1401                return false;
1402            };
1403            match &decl.body {
1404                TypeBody::Refined { refinement, .. } | TypeBody::Opaque { refinement, .. } => {
1405                    !refinement.as_ref().is_some_and(|r| {
1406                        r.predicates
1407                            .iter()
1408                            .any(|p| matches!(p.kind, PredKind::Matches(_)))
1409                    })
1410                }
1411                TypeBody::Sum(s) => s.variants.first().is_some_and(|v| {
1412                    v.payload.iter().all(|f| {
1413                        checker::resolve_type_ref(&f.type_ref, types, tys)
1414                            .is_some_and(|t| prop_binding_generable(t, types, depth - 1, tys))
1415                    })
1416                }),
1417                TypeBody::Record(r) => r.fields.iter().all(|f| {
1418                    checker::resolve_type_ref(&f.type_ref, types, tys)
1419                        .is_some_and(|t| prop_binding_generable(t, types, depth - 1, tys))
1420                }),
1421            }
1422        }
1423        _ => false,
1424    }
1425}
1426
1427/// The refinement of a resolved refined/opaque named type, if any — used by the
1428/// conservative restates-refinement check.
1429fn named_refinement<'a>(
1430    ty: checker::TyId,
1431    types: &'a HashMap<String, Arc<TypeDecl>>,
1432    tys: &Arc<Types>,
1433) -> Option<&'a Refinement> {
1434    let node = tys.get(ty);
1435    let checker::Ty::Named { name, .. } = &*node else {
1436        return None;
1437    };
1438    match &types.get(name)?.body {
1439        TypeBody::Refined { refinement, .. } | TypeBody::Opaque { refinement, .. } => {
1440            refinement.as_ref()
1441        }
1442        _ => None,
1443    }
1444}
1445
1446/// v0.114 (DECISION P): does `pred` merely restate a refinement `bound_var`
1447/// already guarantees? A **conservative, syntactic** check — it fires only when
1448/// the predicate is exactly the refinement over the bound variable, never
1449/// guessing (under-flagging is acceptable; over-flagging is not). Handles the
1450/// `Positive` (`v > 0` / `v >= 1`) and `NonNegative` (`v >= 0`) numeric cases.
1451fn predicate_restates_refinement(pred: &Expr, bound_var: &str, refinement: &Refinement) -> bool {
1452    let ExprKind::BinOp(op, lhs, rhs) = &pred.kind else {
1453        return false;
1454    };
1455    // `<var> <op> <int-literal>` only.
1456    let ExprKind::Ident(id) = &lhs.kind else {
1457        return false;
1458    };
1459    if id.name != bound_var {
1460        return false;
1461    }
1462    let ExprKind::IntLit { value: n, .. } = &rhs.kind else {
1463        return false;
1464    };
1465    let n = *n;
1466    let positive = refinement
1467        .predicates
1468        .iter()
1469        .any(|p| matches!(p.kind, PredKind::Positive));
1470    let non_negative = refinement
1471        .predicates
1472        .iter()
1473        .any(|p| matches!(p.kind, PredKind::NonNegative));
1474    match op {
1475        // `v > 0` / `v >= 1` restate `Positive`.
1476        BinOp::Gt if n == 0 => positive,
1477        BinOp::GtEq if n == 1 => positive,
1478        // `v >= 0` restates `NonNegative`.
1479        BinOp::GtEq if n == 0 => non_negative,
1480        _ => false,
1481    }
1482}
1483
1484/// v0.119 (DECISION D): which state-projection rewrite maps a history predicate
1485/// back into the space an `invariant` / `transition` is written in.
1486#[derive(Clone, Copy)]
1487enum HistoryRestate {
1488    /// An `invariant` reads bare state fields: `s.new.F` ≡ `F`.
1489    Invariant,
1490    /// A `transition` reads `old` / `new`: `s.old` ≡ `old`, `s.new` ≡ `new`.
1491    Transition,
1492}
1493
1494/// `Some(field)` when `e` is `s.new.<field>` (the reached-state projection an
1495/// invariant-restating history predicate uses).
1496fn as_new_field<'a>(e: &'a Expr, s: &str) -> Option<&'a str> {
1497    let ExprKind::FieldAccess { receiver, field } = &e.kind else {
1498        return None;
1499    };
1500    let ExprKind::FieldAccess {
1501        receiver: inner,
1502        field: which,
1503    } = &receiver.kind
1504    else {
1505        return None;
1506    };
1507    let ExprKind::Ident(id) = &inner.kind else {
1508        return None;
1509    };
1510    (id.name == s && which.name == "new").then_some(field.name.as_str())
1511}
1512
1513/// `Some("old"|"new")` when `e` is `s.old` / `s.new` (the step projections a
1514/// transition-restating history predicate uses).
1515fn as_step_root<'a>(e: &'a Expr, s: &str) -> Option<&'a str> {
1516    let ExprKind::FieldAccess { receiver, field } = &e.kind else {
1517        return None;
1518    };
1519    let ExprKind::Ident(id) = &receiver.kind else {
1520        return None;
1521    };
1522    (id.name == s && (field.name == "old" || field.name == "new")).then_some(field.name.as_str())
1523}
1524
1525/// Conservative, span-insensitive structural match (DECISION D): does the history
1526/// predicate `body` (over the step binding `s`) restate the declared predicate
1527/// `decl`, modulo the `mode` state-projection rewrite? Under-flags by design — any
1528/// construct not modelled here compares unequal, so a valid test is never blocked.
1529fn history_pred_matches(body: &Expr, s: &str, decl: &Expr, mode: HistoryRestate) -> bool {
1530    // Leaf equivalences the rewrite establishes.
1531    match mode {
1532        HistoryRestate::Invariant => {
1533            if let (Some(f), ExprKind::Ident(id)) = (as_new_field(body, s), &decl.kind) {
1534                return f == id.name;
1535            }
1536        }
1537        HistoryRestate::Transition => {
1538            if let (Some(root), ExprKind::Ident(id)) = (as_step_root(body, s), &decl.kind) {
1539                return root == id.name;
1540            }
1541        }
1542    }
1543    match (&body.kind, &decl.kind) {
1544        (ExprKind::Paren(x), _) => history_pred_matches(x, s, decl, mode),
1545        (_, ExprKind::Paren(y)) => history_pred_matches(body, s, y, mode),
1546        (ExprKind::IntLit { value: x, .. }, ExprKind::IntLit { value: y, .. }) => x == y,
1547        (ExprKind::BoolLit(x), ExprKind::BoolLit(y)) => x == y,
1548        (ExprKind::StrLit(x), ExprKind::StrLit(y)) => x == y,
1549        (ExprKind::Ident(x), ExprKind::Ident(y)) => x.name == y.name,
1550        (ExprKind::None, ExprKind::None) => true,
1551        (ExprKind::Some(x), ExprKind::Some(y)) => history_pred_matches(x, s, y, mode),
1552        (ExprKind::UnaryOp(o1, x), ExprKind::UnaryOp(o2, y)) => {
1553            o1 == o2 && history_pred_matches(x, s, y, mode)
1554        }
1555        (ExprKind::BinOp(o1, l1, r1), ExprKind::BinOp(o2, l2, r2)) => {
1556            o1 == o2
1557                && history_pred_matches(l1, s, l2, mode)
1558                && history_pred_matches(r1, s, r2, mode)
1559        }
1560        (
1561            ExprKind::FieldAccess {
1562                receiver: r1,
1563                field: f1,
1564            },
1565            ExprKind::FieldAccess {
1566                receiver: r2,
1567                field: f2,
1568            },
1569        ) => f1.name == f2.name && history_pred_matches(r1, s, r2, mode),
1570        (
1571            ExprKind::MethodCall {
1572                receiver: r1,
1573                method: m1,
1574                args: a1,
1575                ..
1576            },
1577            ExprKind::MethodCall {
1578                receiver: r2,
1579                method: m2,
1580                args: a2,
1581                ..
1582            },
1583        ) => {
1584            m1.name == m2.name
1585                && a1.len() == a2.len()
1586                && history_pred_matches(r1, s, r2, mode)
1587                && a1
1588                    .iter()
1589                    .zip(a2)
1590                    .all(|(x, y)| history_pred_matches(x, s, y, mode))
1591        }
1592        (
1593            ExprKind::Call {
1594                name: n1, args: a1, ..
1595            },
1596            ExprKind::Call {
1597                name: n2, args: a2, ..
1598            },
1599        ) => {
1600            n1.name == n2.name
1601                && a1.len() == a2.len()
1602                && a1
1603                    .iter()
1604                    .zip(a2)
1605                    .all(|(x, y)| history_pred_matches(x, s, y, mode))
1606        }
1607        _ => false,
1608    }
1609}
1610
1611/// v0.119 (DECISION D): a history property that merely restates a snapshot/step
1612/// invariant is redundant — the driver only commits states the invariants already
1613/// admit. Recognise the canonical shape `for all run: History[A] { expect
1614/// run.all((s) => P) }` (or `.any`) whose `P` α-matches a declared
1615/// `invariant` (over `s.new`) or `transition` (over `s.old`/`s.new`). Returns the
1616/// body span to flag. Conservative — near-duplicates slip through by design.
1617fn history_restates_invariant(prop: &PropertyDecl, run_var: &str, agent: &AgentDecl) -> bool {
1618    let [stmt] = prop.forall.body.statements.as_slice() else {
1619        return false;
1620    };
1621    let Statement::Expect(e) = stmt else {
1622        return false;
1623    };
1624    // `run.all((s) => P)` / `run.any((s) => P)`.
1625    let ExprKind::MethodCall {
1626        receiver,
1627        method,
1628        args,
1629        ..
1630    } = &e.value.kind
1631    else {
1632        return false;
1633    };
1634    if method.name != "all" && method.name != "any" {
1635        return false;
1636    }
1637    let ExprKind::Ident(recv) = &receiver.kind else {
1638        return false;
1639    };
1640    if recv.name != run_var {
1641        return false;
1642    }
1643    let [arg] = args.as_slice() else {
1644        return false;
1645    };
1646    let ExprKind::Lambda(lam) = &arg.kind else {
1647        return false;
1648    };
1649    let [param] = lam.params.as_slice() else {
1650        return false;
1651    };
1652    let s = &param.name.name;
1653    agent
1654        .invariants
1655        .iter()
1656        .any(|inv| history_pred_matches(&lam.body, s, &inv.predicate, HistoryRestate::Invariant))
1657        || agent
1658            .transitions
1659            .iter()
1660            .any(|tr| history_pred_matches(&lam.body, s, &tr.predicate, HistoryRestate::Transition))
1661}
1662
1663/// v0.119 (ADR 0155): the synthetic type names a `History[Agent]` binding
1664/// registers — a call sum, a step record, and a state record — all keyed off the
1665/// agent name so distinct agents never collide.
1666fn history_call_type_name(agent: &str) -> String {
1667    format!("__History_{agent}_Call")
1668}
1669fn history_step_type_name(agent: &str) -> String {
1670    format!("__History_{agent}_Step")
1671}
1672fn history_state_type_name(agent: &str) -> String {
1673    format!("__History_{agent}_State")
1674}
1675
1676/// The `.call` variant tag for a handler: the handler name with its first letter
1677/// upper-cased (`spend` → `Spend`, `topUp` → `TopUp`). The reader matches this
1678/// with `is` / `match` (`s.call is Spend`).
1679pub fn history_variant_name(handler: &str) -> String {
1680    let mut chars = handler.chars();
1681    match chars.next() {
1682        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1683        None => handler.to_string(),
1684    }
1685}
1686
1687/// The agent's drivable `on call` handlers — the ones a history sequences. Other
1688/// handler kinds (`http`/`cron`/`message`/`open`/`close`) are not RPC entry points
1689/// and are never part of a generated call-history.
1690pub fn history_handlers(agent: &AgentDecl) -> Vec<&Handler> {
1691    agent
1692        .handlers
1693        .iter()
1694        .filter(|h| matches!(h.kind, HandlerKind::Call) && h.method_name.is_some())
1695        .collect()
1696}
1697
1698/// v0.119 (testing track slice 7, ADR 0155): type-check a `for all run:
1699/// History[Agent]` binding. The subject is a *run* of the agent — a generated,
1700/// driven call-history — bound as an ordinary `List[Step]`. Validates the
1701/// DECISION-B rules (agent-only, every handler parameter generable), registers the
1702/// synthetic call-sum / step / state record types into `resolved.types` so the
1703/// predicate's `List` + value surface (`.call is …`, `.old`/`.new`, `.accepted`)
1704/// type-checks, and returns the bound `List[Step]` type.
1705pub fn check_history_binding(
1706    inner: &TypeRef,
1707    span: Span,
1708    resolved: &mut ResolvedCommons,
1709    refs: &mut RefSink,
1710    tys: &Arc<Types>,
1711) -> Result<checker::Ty, CompileError> {
1712    // DECISION B: only an agent has handlers to sequence and reachable states to
1713    // observe. `History[Value]` / `History[List[…]]` is `not_an_agent`.
1714    let TypeRef::Named(agent_id) = inner else {
1715        return Err(CompileError::new(
1716            "bynk.history.not_an_agent",
1717            span,
1718            format!(
1719                "`for all` cannot generate `History[{}]` — only an agent has handlers to sequence",
1720                ts_type_ref_display(inner)
1721            ),
1722        )
1723        .with_note("generate a driven call-history over an agent: `for all run: History[Agent]`"));
1724    };
1725    let Some(agent) = resolved.agents.get(&agent_id.name).cloned() else {
1726        return Err(CompileError::new(
1727            "bynk.history.not_an_agent",
1728            span,
1729            format!(
1730                "`for all run: History[{}]` names `{}`, which is not an agent in scope",
1731                agent_id.name, agent_id.name
1732            ),
1733        )
1734        .with_note(
1735            "only an agent (with handlers and reachable state) can be driven as a history",
1736        ));
1737    };
1738    refs.record(agent_id.span, SymbolKind::Type, &agent_id.name);
1739
1740    let handlers = history_handlers(&agent);
1741    // DECISION B: the agent must be *drivable* — every handler parameter must be
1742    // refinement-generable (the same rule a value `for all` binding obeys), else
1743    // the runner cannot synthesise a call.
1744    for h in &handlers {
1745        for p in &h.params {
1746            let generable = checker::resolve_type_ref(&p.type_ref, &resolved.types, tys)
1747                .is_some_and(|t| prop_binding_generable(t, &resolved.types, PROP_GEN_DEPTH, tys));
1748            if !generable {
1749                return Err(CompileError::new(
1750                    "bynk.history.not_generable",
1751                    span,
1752                    format!(
1753                        "`History[{}]` cannot be driven — handler `{}`'s parameter `{}: {}` is not generable (e.g. a `Matches` refinement)",
1754                        agent_id.name,
1755                        h.method_name.as_ref().map(|m| m.name.as_str()).unwrap_or(""),
1756                        p.name.name,
1757                        ts_type_ref_display(&p.type_ref),
1758                    ),
1759                )
1760                .with_note(
1761                    "every handler parameter must be refinement-generable for the run to be seeded",
1762                ));
1763            }
1764        }
1765    }
1766
1767    // Register the synthetic types (mirrors `register_call_record_types`). The
1768    // driver returns plain objects of exactly these shapes; the checker sees them
1769    // as ordinary record/sum types so `is`, field access, and `implies` apply
1770    // unchanged (the typed-step shape resolving the track's open question).
1771    let state_name = history_state_type_name(&agent_id.name);
1772    let call_name = history_call_type_name(&agent_id.name);
1773    let step_name = history_step_type_name(&agent_id.name);
1774
1775    // `<Agent>State` — the agent's `Cell` fields, exactly as the emitted state
1776    // record (so `.old.balance` / `.new.balance` read a reached state).
1777    let state_fields: Vec<RecordField> = agent
1778        .store_fields
1779        .iter()
1780        .filter(|f| f.kind.head.name == "Cell" && f.kind.args.len() == 1)
1781        .map(|f| RecordField {
1782            name: f.name.clone(),
1783            type_ref: f.kind.args[0].clone(),
1784            refinement: None,
1785            init: None,
1786            span: f.span,
1787        })
1788        .collect();
1789    resolved.types.insert(
1790        state_name.clone(),
1791        Arc::new(TypeDecl {
1792            type_params: Vec::new(),
1793            name: Ident {
1794                name: state_name.clone(),
1795                span,
1796            },
1797            body: TypeBody::Record(RecordBody {
1798                fields: state_fields,
1799                span,
1800            }),
1801            documentation: None,
1802            span,
1803            trivia: Trivia::default(),
1804        }),
1805    );
1806
1807    // `.call` — a sum over the agent's handlers, each variant carrying the
1808    // handler's generated arguments (`Spend { amount }`, `TopUp { amount }`).
1809    let variants: Vec<Variant> = handlers
1810        .iter()
1811        .map(|h| {
1812            let hname = h.method_name.as_ref().expect("call handler has a name");
1813            Variant {
1814                name: Ident {
1815                    name: history_variant_name(&hname.name),
1816                    span: hname.span,
1817                },
1818                payload: h
1819                    .params
1820                    .iter()
1821                    .map(|p| VariantField {
1822                        name: p.name.clone(),
1823                        type_ref: p.type_ref.clone(),
1824                        span: p.span,
1825                    })
1826                    .collect(),
1827                span: hname.span,
1828            }
1829        })
1830        .collect();
1831    resolved.types.insert(
1832        call_name.clone(),
1833        Arc::new(TypeDecl {
1834            type_params: Vec::new(),
1835            name: Ident {
1836                name: call_name.clone(),
1837                span,
1838            },
1839            body: TypeBody::Sum(SumBody {
1840                variants,
1841                embeds: Vec::new(),
1842                span,
1843            }),
1844            documentation: None,
1845            span,
1846            trivia: Trivia::default(),
1847        }),
1848    );
1849
1850    // A `Step` — the driven edge: which call ran (`.call`), whether it committed
1851    // (`.accepted`), and the committed `old` → `new` state pair.
1852    let step_fields = vec![
1853        RecordField {
1854            name: Ident {
1855                name: "call".to_string(),
1856                span,
1857            },
1858            type_ref: TypeRef::Named(Ident {
1859                name: call_name.clone(),
1860                span,
1861            }),
1862            refinement: None,
1863            init: None,
1864            span,
1865        },
1866        RecordField {
1867            name: Ident {
1868                name: "accepted".to_string(),
1869                span,
1870            },
1871            type_ref: TypeRef::Base(BaseType::Bool, span),
1872            refinement: None,
1873            init: None,
1874            span,
1875        },
1876        RecordField {
1877            name: Ident {
1878                name: "old".to_string(),
1879                span,
1880            },
1881            type_ref: TypeRef::Named(Ident {
1882                name: state_name.clone(),
1883                span,
1884            }),
1885            refinement: None,
1886            init: None,
1887            span,
1888        },
1889        RecordField {
1890            name: Ident {
1891                name: "new".to_string(),
1892                span,
1893            },
1894            type_ref: TypeRef::Named(Ident {
1895                name: state_name.clone(),
1896                span,
1897            }),
1898            refinement: None,
1899            init: None,
1900            span,
1901        },
1902    ];
1903    resolved.types.insert(
1904        step_name.clone(),
1905        Arc::new(TypeDecl {
1906            type_params: Vec::new(),
1907            name: Ident {
1908                name: step_name.clone(),
1909                span,
1910            },
1911            body: TypeBody::Record(RecordBody {
1912                fields: step_fields,
1913                span,
1914            }),
1915            documentation: None,
1916            span,
1917            trivia: Trivia::default(),
1918        }),
1919    );
1920
1921    Ok(checker::Ty::List(tys.intern(checker::Ty::Named {
1922        name: step_name,
1923        kind: checker::NamedKind::Record,
1924        args: Vec::new(),
1925    })))
1926}
1927
1928/// v0.114: type-check a generative `property` — its `for all` bindings, the
1929/// optional `where` filter, and the predicate body — in the target's privileged
1930/// view. Bindings type each `x: T`; `where`/`expect` predicates type as pure
1931/// `Bool`; each binding's `T` must be refinement-generable (agents are rejected;
1932/// a `Matches` type must pin); and the body is flagged if it merely restates a
1933/// refinement (DECISION P). v0.119: a `for all run: History[Agent]` binding is a
1934/// driven call-history (the history rung — see [`check_history_binding`]).
1935#[allow(clippy::too_many_arguments)]
1936fn check_property_body(
1937    target_name: &str,
1938    target_kind: UnitKind,
1939    prop: &PropertyDecl,
1940    unit_tables: &HashMap<String, UnitTable>,
1941    unit_uses: &HashMap<String, Vec<String>>,
1942    unit_consumes: &HashMap<String, Vec<String>>,
1943    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
1944    errors: &mut Vec<CompileError>,
1945    refs: &mut RefSink,
1946    tys: &Arc<Types>,
1947) {
1948    let Some((mut resolved, _)) = build_privileged_resolved(
1949        target_name,
1950        unit_tables,
1951        unit_uses,
1952        unit_consumes,
1953        unit_consumes_aliases,
1954    ) else {
1955        return;
1956    };
1957    register_call_record_types(&mut resolved, target_name, unit_tables);
1958    let _ = target_kind;
1959
1960    // Bind each `for all x: T` into the predicate scope, checking generability.
1961    let mut binding_scope: HashMap<String, checker::TyId> = HashMap::new();
1962    let mut binding_types: Vec<(String, Option<checker::TyId>)> = Vec::new();
1963    // v0.119: the single `History[Agent]` binding (run-var, agent), for the
1964    // post-body `restates_invariant` check (DECISION D).
1965    let mut history_binding: Option<(String, AgentDecl)> = None;
1966    for b in &prop.forall.bindings {
1967        // v0.119 (ADR 0155): `for all run: History[Agent]` — the history rung. A
1968        // driven call-history, bound as an ordinary `List[Step]`.
1969        if let TypeRef::History(inner, hspan) = &b.type_ref {
1970            match check_history_binding(inner, *hspan, &mut resolved, refs, tys) {
1971                Ok(step_ty) => {
1972                    if let TypeRef::Named(agent_id) = &**inner
1973                        && let Some(agent) = resolved.agents.get(&agent_id.name)
1974                    {
1975                        history_binding = Some((b.name.name.clone(), agent.clone()));
1976                    }
1977                    binding_scope.insert(b.name.name.clone(), tys.intern(step_ty.clone()));
1978                    binding_types.push((b.name.name.clone(), Some(tys.intern(step_ty))));
1979                }
1980                Err(err) => {
1981                    errors.push(err);
1982                    binding_types.push((b.name.name.clone(), None));
1983                }
1984            }
1985            continue;
1986        }
1987        // Agents are not a value type — a fabricated state that satisfies every
1988        // invariant need not be reachable (DECISION P); reject up front.
1989        if let TypeRef::Named(id) = &b.type_ref
1990            && resolved.agents.contains_key(&id.name)
1991        {
1992            errors.push(
1993                CompileError::new(
1994                    "bynk.val.agent_not_generable",
1995                    b.type_ref.span(),
1996                    format!(
1997                        "`for all {}: {}` cannot generate an agent — a fabricated agent state need not be reachable",
1998                        b.name.name, id.name
1999                    ),
2000                )
2001                .with_note(
2002                    "generate behaviour over an agent via handler sequences (the history rung), not fabricated states",
2003                ),
2004            );
2005            binding_types.push((b.name.name.clone(), None));
2006            continue;
2007        }
2008        let ty = match checker::resolve_type_ref(&b.type_ref, &resolved.types, tys) {
2009            Some(t) => {
2010                record_type_refs_in_property(&b.type_ref, &resolved, refs);
2011                t
2012            }
2013            None => {
2014                errors.push(CompileError::new(
2015                    "bynk.val.unknown_type",
2016                    b.type_ref.span(),
2017                    format!(
2018                        "`for all {}: {}` names a type that does not resolve",
2019                        b.name.name,
2020                        ts_type_ref_display(&b.type_ref)
2021                    ),
2022                ));
2023                binding_types.push((b.name.name.clone(), None));
2024                continue;
2025            }
2026        };
2027        if !prop_binding_generable(ty, &resolved.types, PROP_GEN_DEPTH, tys) {
2028            errors.push(
2029                CompileError::new(
2030                    "bynk.val.needs_pin",
2031                    b.type_ref.span(),
2032                    format!(
2033                        "`for all {}: {}` cannot generate a value (e.g. a `Matches` refinement); a property cannot bind it",
2034                        b.name.name,
2035                        ts_type_ref_display(&b.type_ref)
2036                    ),
2037                )
2038                .with_note("supply the witness in a `case` with a pinned `Val[T](...)` instead"),
2039            );
2040        }
2041        binding_scope.insert(b.name.name.clone(), ty);
2042        binding_types.push((b.name.name.clone(), Some(ty)));
2043    }
2044
2045    // Type the `where`/body predicates in the target's privileged view with the
2046    // bindings in scope — mirroring the `case` body context.
2047    let mut expr_types: HashMap<ExprId, checker::TypedExpr> = HashMap::new();
2048    let mut callees: HashMap<ExprId, checker::Callee> = HashMap::new();
2049    let unit_span = prop.span;
2050    let synthetic_return = TypeRef::Effect(
2051        Box::new(TypeRef::Result(
2052            Box::new(TypeRef::Unit(unit_span)),
2053            Box::new(TypeRef::ValidationError(unit_span)),
2054            unit_span,
2055        )),
2056        unit_span,
2057    );
2058    let mut capability_info_map: HashMap<String, checker::CapabilityInfo> = HashMap::new();
2059    if let Some(table) = unit_tables.get(target_name) {
2060        for (name, decl) in &table.capabilities {
2061            let ops = decl
2062                .ops
2063                .iter()
2064                .map(|op| build_capability_op_info(op, &resolved.types, tys))
2065                .collect();
2066            capability_info_map.insert(
2067                name.clone(),
2068                checker::CapabilityInfo {
2069                    name: name.clone(),
2070                    ops,
2071                },
2072            );
2073        }
2074    }
2075    let given_declared: Vec<String> = capability_info_map.keys().cloned().collect();
2076    let return_ty = checker::resolve_type_ref(&synthetic_return, &resolved.types, tys).unwrap();
2077    let return_ty_span = prop.span;
2078    let mut no_hints = HintSink::new();
2079    let mut no_locals = LocalsSink::new();
2080    let mut no_requirements = RequirementSink::new();
2081    // The optional `where` filter is checked first (against `Bool`), sharing
2082    // `check_body`'s `Ctx` with the body below; the body is the one predicate
2083    // surface: `expect`s self-check as `Bool`.
2084    let _ = checker::check_body(
2085        &resolved,
2086        &prop.forall.body,
2087        return_ty,
2088        return_ty_span,
2089        binding_scope,
2090        checker::CapabilityCtx {
2091            capabilities: capability_info_map.clone(),
2092            declared_capabilities: capability_info_map,
2093            given_remaining: given_declared.iter().cloned().collect(),
2094            given_used: HashSet::new(),
2095            given_entries: Vec::new(),
2096            given_anchor: None,
2097        },
2098        target_test_services(unit_tables.get(target_name)),
2099        target_test_actors(unit_tables.get(target_name)),
2100        prop.forall.where_pred.as_ref(),
2101        checker::CheckSinks {
2102            tys,
2103            expr_types: &mut expr_types,
2104            errors,
2105            refs,
2106            hints: &mut no_hints,
2107            locals: &mut no_locals,
2108            requirements: &mut no_requirements,
2109            callees: &mut callees,
2110        },
2111    );
2112
2113    // Conservative restates-refinement flag: a single-binding property whose
2114    // body is exactly `expect <pred>` restating the bound var's refinement.
2115    if let [(var, Some(ty))] = binding_types.as_slice()
2116        && let Some(refinement) = named_refinement(*ty, &resolved.types, tys)
2117        && let [stmt] = prop.forall.body.statements.as_slice()
2118        && let Statement::Expect(e) = stmt
2119        && predicate_restates_refinement(&e.value, var, refinement)
2120    {
2121        errors.push(
2122            CompileError::new(
2123                "bynk.property.restates_refinement",
2124                prop.forall.body.span,
2125                format!(
2126                    "property `{}` merely re-checks a refinement type `{}` already guarantees",
2127                    prop.name,
2128                    ty.display(tys)
2129                ),
2130            )
2131            .with_note(
2132                "a property earns its keep by asserting behaviour over valid inputs, not by restating the type's refinement",
2133            ),
2134        );
2135    }
2136
2137    // v0.119 (DECISION D): a history property that merely restates a declared
2138    // `invariant` / `transition` re-checks a guarantee every reached state already
2139    // has (the driver only commits admissible states). Conservative — near-
2140    // duplicates slip through by design.
2141    if let Some((run_var, agent)) = &history_binding
2142        && history_restates_invariant(prop, run_var, agent)
2143    {
2144        errors.push(
2145            CompileError::new(
2146                "bynk.history.restates_invariant",
2147                prop.forall.body.span,
2148                format!(
2149                    "history property `{}` merely re-checks a guarantee agent `{}`'s `invariant`/`transition` already enforces on every reached state",
2150                    prop.name, agent.name.name
2151                ),
2152            )
2153            .with_note(
2154                "a history property earns its keep by asserting a cross-step protocol, not by restating a per-state invariant",
2155            ),
2156        );
2157    }
2158}
2159
2160/// Record type references named by a `for all` binding so cross-file edges and
2161/// go-to-definition resolve for a property's generated types.
2162fn record_type_refs_in_property(
2163    type_ref: &TypeRef,
2164    resolved: &ResolvedCommons,
2165    refs: &mut RefSink,
2166) {
2167    checker::record_type_refs(type_ref, &resolved.types, &HashSet::new(), refs);
2168}
2169
2170/// Build a [`resolver::ResolvedCommons`] backed by `owning_unit`'s privileged
2171/// view: its types, fns, methods, plus types/fns from every commons it
2172/// `uses`, plus exported types from every consumed context. The same
2173/// shape used by the production pipeline. Returns the [`ResolvedCommons`]
2174/// plus a synthetic commons span for the test.
2175pub fn build_privileged_resolved(
2176    owning_unit: &str,
2177    unit_tables: &HashMap<String, UnitTable>,
2178    unit_uses: &HashMap<String, Vec<String>>,
2179    unit_consumes: &HashMap<String, Vec<String>>,
2180    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
2181) -> Option<(ResolvedCommons, ())> {
2182    let local = unit_tables.get(owning_unit)?;
2183    let mut types = local.types.clone();
2184    let mut fns = local.fns.clone();
2185    let mut methods = local.methods.clone();
2186    if let Some(targets) = unit_uses.get(owning_unit) {
2187        for t in targets {
2188            if let Some(used) = unit_tables.get(t) {
2189                for (n, d) in &used.types {
2190                    types.entry(n.clone()).or_insert_with(|| d.clone());
2191                }
2192                for (n, d) in &used.fns {
2193                    fns.entry(n.clone()).or_insert_with(|| d.clone());
2194                }
2195                for (n, mt) in &used.methods {
2196                    let entry = methods.entry(n.clone()).or_default();
2197                    for (m, decl) in &mt.instance {
2198                        entry
2199                            .instance
2200                            .entry(m.clone())
2201                            .or_insert_with(|| decl.clone());
2202                    }
2203                    for (m, decl) in &mt.statics {
2204                        entry
2205                            .statics
2206                            .entry(m.clone())
2207                            .or_insert_with(|| decl.clone());
2208                    }
2209                }
2210            }
2211        }
2212    }
2213    // Consumed-context types come in too (only the exported ones).
2214    if let Some(consumed) = unit_consumes.get(owning_unit) {
2215        for t in consumed {
2216            if let Some(used) = unit_tables.get(t) {
2217                for (n, d) in &used.types {
2218                    types.entry(n.clone()).or_insert_with(|| d.clone());
2219                }
2220                for (n, mt) in &used.methods {
2221                    let entry = methods.entry(n.clone()).or_default();
2222                    for (m, decl) in &mt.instance {
2223                        entry
2224                            .instance
2225                            .entry(m.clone())
2226                            .or_insert_with(|| decl.clone());
2227                    }
2228                }
2229            }
2230        }
2231    }
2232    let cross_context = build_cross_context_info(
2233        owning_unit,
2234        unit_consumes,
2235        unit_consumes_aliases,
2236        unit_uses,
2237        unit_tables,
2238    );
2239    let synthetic_commons = Commons {
2240        name: QualifiedName {
2241            parts: owning_unit
2242                .split('.')
2243                .map(|part| Ident {
2244                    name: part.to_string(),
2245                    span: Span::default(),
2246                })
2247                .collect(),
2248            span: Span::default(),
2249        },
2250        items: Vec::new(),
2251        uses: Vec::new(),
2252        documentation: None,
2253        form: CommonsForm::Brace,
2254        span: Span::default(),
2255        trivia: Trivia::default(),
2256        trailing_comments: Vec::new(),
2257    };
2258    let agents_for_resolved = unit_tables
2259        .get(owning_unit)
2260        .map(|t| t.agents.clone())
2261        .unwrap_or_default();
2262    let no_local_events = HashMap::new();
2263    let resolved = ResolvedCommons::new(
2264        synthetic_commons,
2265        types,
2266        &local.types,
2267        fns,
2268        methods,
2269        agents_for_resolved,
2270        // "Privileged" test/stub-body resolved — deliberately relaxed, not a
2271        // real context emission subject to the rebrand — so events stay
2272        // empty rather than reading `local`'s.
2273        &no_local_events,
2274        cross_context,
2275        HashMap::new(),
2276        false,
2277        HashSet::new(),
2278    );
2279    Some((resolved, ()))
2280}