Skip to main content

brink_analyzer/
protocols.rs

1//! NS-A3 (issue #1109, docs/stdlib-spec.md §9.6): the protocol registry.
2//!
3//! A **CLOSED** set of compiler-declared protocols — `display`, `compare`,
4//! `iterate` — that user `STRUCT` types may *implement* but never *declare*.
5//! No bounds, no user generics, no user-defined protocols (#1090 guards the
6//! door). Three concerns live here:
7//!
8//! - **The registry itself** ([`Protocol`]): each entry's method name,
9//!   signature shape, and per-protocol **effect contract**
10//!   (`display`/`compare`: pure·silent·total; `iterate`'s `next`:
11//!   writes-receiver·silent·total). The set is closed by construction — the
12//!   enum IS the registry.
13//! - **Name reservation** ([`check_reserved_names`], F6 ruled 2026-07-19):
14//!   the method names `display`/`compare`/`next` are reserved under the
15//!   brink dialect; an author declaration of any callable or
16//!   value-bindable kind is a hard `E113`, not an E035-lineage warning —
17//!   a shadowed `display` would make interpolation untrustworthy (F1 routes
18//!   both interpolation and `string()` through the display path).
19//! - **Impl validation** ([`check_protocol_impls`]): a registered impl's
20//!   declared shape is checked against the protocol's signature (`E115`)
21//!   and its inferred effect row against the protocol's contract (`E114`,
22//!   exceedance-only — the `E103`/`E108`/`E109` posture, riding NS-A2's
23//!   `emits`/`tags`/`faults` row dimensions).
24//!
25//! ## v1 has no impl *spelling*
26//!
27//! The implementation spelling (attribute vs impl-block) is ⏳ for the
28//! code-dialect sitting, and F6 reserves the method names themselves, so
29//! the brink dialect cannot honestly host a source-level impl declaration
30//! today. [`ProtocolImplDecl`] is therefore a *programmatic* registration
31//! surface (the `HostManifest` precedent: project-level metadata supplied
32//! beside the source, not invented syntax inside it) — the validation
33//! machinery is real and fully exercised, and the future surface spelling
34//! lowers into this same table. Consequences, all deliberate:
35//!
36//! - Structural `display` defaults (field-order rendering, in
37//!   `brink-runtime::value_ops`) serve every struct — a user impl would
38//!   *override* the default, and nothing can register one from source yet.
39//! - `compare` has **no structural default** (§4b: field declaration order
40//!   must not silently define semantics), so structs stay not-orderable at
41//!   the ordering verbs (`NotOrderable`, since NS-A1) until a compare impl
42//!   is registrable — wiring registered compares into the VM's ordering
43//!   verbs is Wave A4's scope, alongside `sort`/`sort_by`/`sorted_by`.
44//! - `iterate`'s v1 consumer is `for` over the closed builtin iterable set
45//!   ([`iterate_element_ty`] is that unification point on the checker
46//!   side); user iterables joining the verb ecosystem stays #1090-gated.
47
48use brink_ir::{
49    BlockStmt, Content, ContentPart, Diagnostic, DiagnosticCode, ElseBranch, Expr, FileId, HirFile,
50    HostManifest, IfStmt, Knot, Name, Param, ResolutionMap, Stmt, StringPart, SymbolIndex,
51    TypeExpr,
52};
53
54use crate::infer::{EffectRow, Ty};
55
56/// One entry of the closed protocol registry (stdlib-spec §9.6). The enum
57/// is the registry: adding an entry is a compiler change by construction.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
59pub enum Protocol {
60    /// `fn display(self: T): string`, row ⊆ pure·silent·total. Feeds the
61    /// §1.6 display boundary; F1 (ruled 2026-07-19): BOTH interpolation
62    /// and the `string()` conversion intrinsic dispatch through this one
63    /// path (`brink-runtime::value_ops::stringify` is the runtime seam).
64    Display,
65    /// `fn compare(a: T, b: T): int`, row ⊆ pure·silent·total. Slots user
66    /// types into the §4b ordering doctrine; no structural default.
67    Compare,
68    /// Pull-shaped iteration: `next(ref Self): Option[T]`, row ⊆
69    /// writes-receiver·silent·total, laws attached ("every element once;
70    /// `none` terminal and sticky" — property-harness enforced in
71    /// `brink-runtime::iter`).
72    Iterate,
73}
74
75impl Protocol {
76    /// Every registry entry, in declaration order.
77    pub const ALL: [Protocol; 3] = [Protocol::Display, Protocol::Compare, Protocol::Iterate];
78
79    /// The protocol's reserved method name (F6): the name an impl answers
80    /// to, and the name authors may not declare.
81    #[must_use]
82    pub fn method_name(self) -> &'static str {
83        match self {
84            Protocol::Display => "display",
85            Protocol::Compare => "compare",
86            Protocol::Iterate => "next",
87        }
88    }
89
90    /// Human-readable name of the protocol itself (diagnostics).
91    #[must_use]
92    pub fn protocol_name(self) -> &'static str {
93        match self {
94            Protocol::Display => "display",
95            Protocol::Compare => "compare",
96            Protocol::Iterate => "iterate",
97        }
98    }
99
100    /// Declared parameter count of the protocol method.
101    #[must_use]
102    pub fn arity(self) -> usize {
103        match self {
104            Protocol::Display | Protocol::Iterate => 1,
105            Protocol::Compare => 2,
106        }
107    }
108
109    /// Whether the receiver (first) parameter must be `ref`. Only
110    /// `iterate`'s `next` mutates its receiver — that write is a `ref`
111    /// param write, invisible to the *global* effect row, which is why one
112    /// row bound ([`EffectRow::is_empty`]) serves all three contracts.
113    #[must_use]
114    pub fn receiver_is_ref(self) -> bool {
115        matches!(self, Protocol::Iterate)
116    }
117
118    /// The contract phrase used in diagnostics.
119    #[must_use]
120    pub fn contract_phrase(self) -> &'static str {
121        match self {
122            Protocol::Display | Protocol::Compare => "pure\u{b7}silent\u{b7}total",
123            Protocol::Iterate => "writes-receiver\u{b7}silent\u{b7}total",
124        }
125    }
126}
127
128/// Whether `name` is a reserved protocol method name (F6, ruled
129/// 2026-07-19): `display`, `compare`, or `next`.
130#[must_use]
131pub fn is_reserved_protocol_name(name: &str) -> bool {
132    Protocol::ALL.iter().any(|p| p.method_name() == name)
133}
134
135/// The element type `for` binds when iterating `iterable` — the checker
136/// side of the closed builtin iterable set, unified under the registry
137/// (stdlib-spec §9.6: "`for` is the only v1 consumer"). Arrays iterate
138/// values; maps iterate **keys** in insertion order
139/// (docs/t1b-surface-spec.md §2). Everything else is not iterable v1 —
140/// `None` (the caller falls back to `Unknown`; the runtime faults
141/// `NotIndexable`, conservatively carried in the `faults` row dimension).
142#[must_use]
143pub fn iterate_element_ty(iterable: &Ty) -> Option<Ty> {
144    match iterable {
145        Ty::Array(elem) => Some((**elem).clone()),
146        Ty::Map(key, _) => Some((**key).clone()),
147        // Ranges iterate their int elements (NS-A5, F7 — `for i in 0..n`;
148        // the refinement bit is irrelevant to iteration: an empty range
149        // runs zero times, emptiness is load-bearing).
150        Ty::Range { .. } => Some(Ty::Int),
151        _ => None,
152    }
153}
154
155/// The value type bound by `for k, v in m`'s second binding (B2, issue
156/// #1461, docs/stdlib-spec.md §5/§9's F10 ruling — two-binding map
157/// iteration is the pair story `entries()` never got). Only maps have a
158/// "value at key"; arrays and ranges iterate a single element with no
159/// paired value, so they're not represented here at all — a caller
160/// (`infer::body`'s `BlockStmt::For` arm) falls back to `Ty::Unknown` for
161/// anything this returns `None` for, the same permissive-at-compile
162/// posture [`iterate_element_ty`]'s own callers already rely on.
163#[must_use]
164pub fn iterate_val_ty(iterable: &Ty) -> Option<Ty> {
165    match iterable {
166        Ty::Map(_, val) => Some((**val).clone()),
167        _ => None,
168    }
169}
170
171// ─── F6: reserved-name declarations (E113) ──────────────────────────────
172
173/// Check one file for author declarations of the reserved protocol method
174/// names (`E113`, hard error). Brink-dialect-only — the caller
175/// (`per_file_diagnostics`) gates the call, mirroring the annotation-
176/// content precedent: under `strict-ink` there is no protocol registry and
177/// vanilla ink identifiers stay untouched.
178///
179/// Covered declaration kinds: knots/stitches (including functions), their
180/// params, `VAR`/`CONST`, `EXTERNAL`, body temps, `for`-loop variables, and
181/// a lambda's own `|…|` param row (issue #1773) — every kind that can bind
182/// a callable or a value (a fn-value in a temp named `display` would
183/// capture call-position dispatch). A lambda's params are checked at any
184/// expression depth the lambda literal can be reached from — see
185/// [`walk_expr_for_lambdas`]. Deliberately *not* covered: `LIST`/`STRUCT`
186/// type names and `LIST` members — type names aren't callable, and list
187/// members are value-position-only vocabulary (`next` is plausible
188/// narrative domain language); reserving them would over-reach F6's
189/// rationale. Also not covered: a `temp`/for-loop variable/`as` binding
190/// declared *inside* a lambda's own body — only the lambda's param row
191/// itself is checked (asymmetric with `Expr::Fragment`'s block-capture
192/// arm, which does check declarations via `walk_stmts`).
193#[must_use]
194pub fn check_reserved_names(files: &[(FileId, &HirFile)]) -> Vec<Diagnostic> {
195    let mut out = Vec::new();
196    for &(file, hir) in files {
197        let mut push = |name: &Name, what: &str| {
198            if is_reserved_protocol_name(&name.text) {
199                out.push(Diagnostic {
200                    file,
201                    range: name.range,
202                    code: DiagnosticCode::E113,
203                    message: format!(
204                        "`{}` is a reserved protocol method name (stdlib-spec \u{a7}9.6) and cannot name a {what}",
205                        name.text
206                    ),
207                });
208            }
209        };
210        for var in &hir.variables {
211            push(&var.name, "VAR");
212            walk_expr_for_lambdas(&var.value, &mut push);
213        }
214        for cst in &hir.constants {
215            push(&cst.name, "CONST");
216            walk_expr_for_lambdas(&cst.value, &mut push);
217        }
218        for ext in &hir.externals {
219            push(&ext.name, "EXTERNAL");
220        }
221        for knot in &hir.knots {
222            push(&knot.name, "knot or function");
223            walk_params(&knot.params, &mut push);
224            walk_stmts(&knot.body.stmts, &mut push);
225            for stitch in &knot.stitches {
226                push(&stitch.name, "stitch");
227                walk_params(&stitch.params, &mut push);
228                walk_stmts(&stitch.body.stmts, &mut push);
229            }
230        }
231        walk_stmts(&hir.root_content.stmts, &mut push);
232    }
233    out
234}
235
236fn walk_params(params: &[Param], push: &mut impl FnMut(&Name, &str)) {
237    for p in params {
238        push(&p.name, "parameter");
239    }
240}
241
242/// Find every `Expr::Lambda` reachable from `expr` — including nested
243/// arbitrarily deep inside another expression (`f(|display| display)`), and
244/// nested inside the lambda's *own* body (`|x| { let f = |display| display;
245/// f() }`, via [`brink_ir::LambdaBody::all_exprs`], the "does this construct
246/// occur anywhere inside" helper built for exactly this shape — issue
247/// #1764) — and, for each one found, push its params exactly like
248/// [`walk_params`] does for a top-level fn/knot/stitch param (issue #1773:
249/// same reserved-name rule, same declaration-site treatment, regardless of
250/// which kind of param row it sits on).
251///
252/// Mirrors the shape of `hir::visit::walk_expr` / this crate's other
253/// hand-rolled expression collectors (e.g. `comparator_contract::
254/// collect_expr`) rather than introducing a third — every `Expr` variant
255/// that can hold a nested expression is descended; the only ones skipped
256/// (`Int`/`Float`/`Bool`/`Null`/`Path`/`DivertTarget`/`ListLiteral`) are
257/// leaves that can never contain a lambda literal.
258fn walk_expr_for_lambdas(expr: &Expr, push: &mut impl FnMut(&Name, &str)) {
259    match expr {
260        Expr::Lambda(l) => {
261            walk_params(&l.params, push);
262            for e in l.body.all_exprs() {
263                walk_expr_for_lambdas(e, push);
264            }
265        }
266        Expr::Call(_path, args) => {
267            for arg in args {
268                walk_expr_for_lambdas(arg, push);
269            }
270        }
271        Expr::Prefix(_, inner) | Expr::Postfix(inner, _) => walk_expr_for_lambdas(inner, push),
272        Expr::Infix(ie) => {
273            walk_expr_for_lambdas(&ie.lhs, push);
274            walk_expr_for_lambdas(&ie.rhs, push);
275        }
276        Expr::String(s) => {
277            for part in &s.parts {
278                if let StringPart::Interpolation(e) = part {
279                    walk_expr_for_lambdas(e, push);
280                }
281            }
282        }
283        Expr::ArrayLiteral(a) => {
284            for e in &a.elements {
285                walk_expr_for_lambdas(e, push);
286            }
287        }
288        Expr::MapLiteral(m) => {
289            for (k, v) in &m.entries {
290                walk_expr_for_lambdas(k, push);
291                walk_expr_for_lambdas(v, push);
292            }
293        }
294        Expr::Index(idx) => {
295            walk_expr_for_lambdas(&idx.base, push);
296            walk_expr_for_lambdas(&idx.index, push);
297        }
298        Expr::StructLiteral(sl) => {
299            for (_name, val) in &sl.fields {
300                walk_expr_for_lambdas(val, push);
301            }
302        }
303        Expr::FieldAccess(fa) => walk_expr_for_lambdas(&fa.base, push),
304        // T1c `#fn(target, args…)`: the target is a static path, not an
305        // `Expr` child (same shape as `Call`'s path) — only bound args
306        // descend.
307        Expr::FnLiteral(fl) => {
308            for arg in &fl.args {
309                walk_expr_for_lambdas(arg, push);
310            }
311        }
312        Expr::RefArg(ra) => walk_expr_for_lambdas(&ra.operand, push),
313        Expr::Range(r) => {
314            walk_expr_for_lambdas(&r.start, push);
315            walk_expr_for_lambdas(&r.end, push);
316        }
317        // Block-capture fragment (issue #1839): not constructible from
318        // surface syntax, but it embeds real `Stmt`s the ordinary weave walk
319        // already knows how to visit — reuse `walk_stmts` rather than
320        // growing a second statement vocabulary here.
321        Expr::Fragment(stmts) => walk_stmts(stmts, push),
322        Expr::Int(_)
323        | Expr::Float(_)
324        | Expr::Bool(_)
325        | Expr::Null
326        | Expr::Path(_)
327        | Expr::DivertTarget(_)
328        | Expr::ListLiteral(_) => {}
329    }
330}
331
332/// Recursive walk over weave-level statements, visiting every declaration
333/// site a temp or loop variable can hide in (the `strict.rs`
334/// `collect_temps_*` walk, extended to choice bodies and continuations) —
335/// plus, additively, every position an `Expr` can sit in, so a lambda
336/// literal reachable from any of them gets its params checked too (issue
337/// #1773).
338fn walk_stmts(stmts: &[Stmt], push: &mut impl FnMut(&Name, &str)) {
339    for stmt in stmts {
340        match stmt {
341            Stmt::TempDecl(t) => {
342                push(&t.name, "temp");
343                if let Some(v) = &t.value {
344                    walk_expr_for_lambdas(v, push);
345                }
346            }
347            Stmt::Content(c) => walk_content(c, push),
348            Stmt::ChoiceSet(cs) => {
349                for choice in &cs.choices {
350                    // Guard-`as` binding (issue #1508) — same treatment as
351                    // `Stmt::Conditional`'s `branch.binding` a few arms
352                    // down: it's a declaration site a temp/loop variable
353                    // can hide behind, per this function's own doc.
354                    if let Some(binding) = &choice.binding {
355                        push(binding, "binding");
356                    }
357                    if let Some(cond) = &choice.condition {
358                        walk_expr_for_lambdas(cond, push);
359                    }
360                    // Native choice labels (`* Gold: {fmt(...)}`) lower
361                    // interpolations into these three `Content` regions,
362                    // not into `choice.body.stmts` — issue #1773 review: a
363                    // lambda param reserved-name shadow in a choice label
364                    // was still unreached without this.
365                    for c in [
366                        &choice.start_content,
367                        &choice.bracket_content,
368                        &choice.inner_content,
369                    ]
370                    .into_iter()
371                    .flatten()
372                    {
373                        walk_content(c, push);
374                    }
375                    walk_stmts(&choice.body.stmts, push);
376                }
377                walk_stmts(&cs.continuation.stmts, push);
378            }
379            Stmt::LabeledBlock(b) => walk_stmts(&b.stmts, push),
380            Stmt::Conditional(c) => {
381                for branch in &c.branches {
382                    if let Some(binding) = &branch.binding {
383                        push(binding, "binding");
384                    }
385                    if let Some(cond) = &branch.condition {
386                        walk_expr_for_lambdas(cond, push);
387                    }
388                    walk_stmts(&branch.body.stmts, push);
389                }
390            }
391            Stmt::Sequence(s) => {
392                for branch in &s.branches {
393                    walk_stmts(&branch.body.stmts, push);
394                }
395            }
396            Stmt::LogicBlock(lb) => walk_block_stmts(&lb.stmts, push),
397            Stmt::Divert(d) => {
398                for arg in &d.target.args {
399                    walk_expr_for_lambdas(arg, push);
400                }
401            }
402            Stmt::TunnelCall(tc) => {
403                for target in &tc.targets {
404                    for arg in &target.args {
405                        walk_expr_for_lambdas(arg, push);
406                    }
407                }
408            }
409            Stmt::ThreadStart(ts) => {
410                for arg in &ts.target.args {
411                    walk_expr_for_lambdas(arg, push);
412                }
413            }
414            Stmt::Assignment(a) => {
415                walk_expr_for_lambdas(&a.target, push);
416                walk_expr_for_lambdas(&a.value, push);
417            }
418            Stmt::Return(r) => {
419                if let Some(v) = &r.value {
420                    walk_expr_for_lambdas(v, push);
421                }
422                for arg in &r.onwards_args {
423                    walk_expr_for_lambdas(arg, push);
424                }
425            }
426            Stmt::ExprStmt(e) | Stmt::AttachElement(e) => walk_expr_for_lambdas(e, push),
427            Stmt::Await(a) => {
428                if let Some(cond) = &a.condition {
429                    walk_expr_for_lambdas(cond, push);
430                }
431            }
432            Stmt::EndOfLine | Stmt::EndElementRun => {}
433        }
434    }
435}
436
437fn walk_content(content: &Content, push: &mut impl FnMut(&Name, &str)) {
438    for part in &content.parts {
439        walk_content_part(part, push);
440    }
441}
442
443fn walk_content_part(part: &ContentPart, push: &mut impl FnMut(&Name, &str)) {
444    match part {
445        ContentPart::InlineConditional(c) => {
446            for branch in &c.branches {
447                if let Some(cond) = &branch.condition {
448                    walk_expr_for_lambdas(cond, push);
449                }
450                walk_stmts(&branch.body.stmts, push);
451            }
452        }
453        ContentPart::InlineSequence(s) => {
454            for branch in &s.branches {
455                walk_stmts(&branch.body.stmts, push);
456            }
457        }
458        // A span can nest a conditional/sequence (§4.3), each with its own
459        // statement bodies to walk.
460        ContentPart::Span(span) => {
461            for child in &span.children {
462                walk_content_part(child, push);
463            }
464        }
465        ContentPart::Interpolation(e) => walk_expr_for_lambdas(e, push),
466        ContentPart::Text(_) | ContentPart::Glue | ContentPart::Spring => {}
467    }
468}
469
470/// Logic-block statements (`~ { … }`): temps, `for`-loop variables, and
471/// every nested block shape — plus, additively, every `Expr`-bearing
472/// position (issue #1773; see [`walk_stmts`]'s doc).
473fn walk_block_stmts(stmts: &[BlockStmt], push: &mut impl FnMut(&Name, &str)) {
474    for stmt in stmts {
475        match stmt {
476            BlockStmt::TempDecl(t) => {
477                push(&t.name, "temp");
478                if let Some(v) = &t.value {
479                    walk_expr_for_lambdas(v, push);
480                }
481            }
482            BlockStmt::If(i) => walk_if(i, push),
483            BlockStmt::While(w) => {
484                if let Some(binding) = &w.binding {
485                    push(binding, "binding");
486                }
487                walk_expr_for_lambdas(&w.condition, push);
488                walk_block_stmts(&w.body, push);
489            }
490            BlockStmt::For(f) => {
491                push(&f.var_name, "for-loop variable");
492                if let Some(val_name) = &f.val_name {
493                    push(val_name, "for-loop variable");
494                }
495                walk_expr_for_lambdas(&f.iterable, push);
496                walk_block_stmts(&f.body, push);
497            }
498            BlockStmt::Assignment(a) => {
499                walk_expr_for_lambdas(&a.target, push);
500                walk_expr_for_lambdas(&a.value, push);
501            }
502            BlockStmt::Return(r) => {
503                if let Some(v) = &r.value {
504                    walk_expr_for_lambdas(v, push);
505                }
506                for arg in &r.onwards_args {
507                    walk_expr_for_lambdas(arg, push);
508                }
509            }
510            BlockStmt::ExprStmt(e) => walk_expr_for_lambdas(e, push),
511            BlockStmt::Await(a) => {
512                if let Some(cond) = &a.condition {
513                    walk_expr_for_lambdas(cond, push);
514                }
515            }
516            BlockStmt::Break(_) | BlockStmt::Continue(_) => {}
517        }
518    }
519}
520
521fn walk_if(i: &IfStmt, push: &mut impl FnMut(&Name, &str)) {
522    // B1b (issue #1475): the `as` binding declares a name, so it is a
523    // reserved-protocol-name site exactly like a `temp` or a `for` variable.
524    if let Some(binding) = &i.binding {
525        push(binding, "binding");
526    }
527    walk_expr_for_lambdas(&i.condition, push);
528    walk_block_stmts(&i.body, push);
529    match &i.else_branch {
530        Some(ElseBranch::ElseIf(inner)) => walk_if(inner, push),
531        Some(ElseBranch::Else(stmts)) => walk_block_stmts(stmts, push),
532        None => {}
533    }
534}
535
536// ─── Impl registration + validation (E114/E115) ─────────────────────────
537
538/// One protocol impl registration: "`function` implements `protocol` for
539/// the declared `STRUCT` named `type_name`". Programmatic v1 (see the
540/// module doc) — the future source spelling lowers into this same shape.
541#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct ProtocolImplDecl {
543    pub protocol: Protocol,
544    /// The declared `STRUCT` name the impl attaches to.
545    pub type_name: String,
546    /// The declared function (knot with `is_function`) that implements the
547    /// protocol method.
548    pub function: String,
549}
550
551/// Validate registered protocol impls: shape against the protocol's
552/// signature (`E115`) and inferred effect row against the protocol's
553/// contract (`E114`). Returns all diagnostics; an impl that fails a shape
554/// check is not row-checked (the `E102`-before-`E103` posture — don't
555/// stack a second diagnostic on an impl that can't even be resolved).
556///
557/// Effect rows are computed via [`crate::infer::effects_project`] only
558/// when at least one impl passes shape validation — an impl-free project
559/// (today: every project) never pays for effect inference here.
560///
561/// Diagnostics carry the impl function's declaration range where
562/// resolvable, else the file-start range of the first file (registration
563/// is not a source construct yet, so there is no registration site to
564/// point at).
565#[must_use]
566pub fn check_protocol_impls(
567    files: &[(FileId, &HirFile)],
568    index: &SymbolIndex,
569    resolutions: &ResolutionMap,
570    host_manifest: Option<&HostManifest>,
571    impls: &[ProtocolImplDecl],
572) -> Vec<Diagnostic> {
573    let mut out = Vec::new();
574    if impls.is_empty() {
575        return out;
576    }
577
578    let struct_names: std::collections::BTreeSet<&str> = files
579        .iter()
580        .flat_map(|(_, hir)| hir.structs.iter())
581        .map(|s| s.name.text.as_str())
582        .collect();
583
584    // Shape-validated impls, with the declaring knot located for row
585    // lookup and diagnostic placement.
586    let mut checked: Vec<(&ProtocolImplDecl, FileId, &Knot)> = Vec::new();
587    let mut seen: std::collections::BTreeSet<(Protocol, &str)> = std::collections::BTreeSet::new();
588
589    for decl in impls {
590        let Some((file, knot)) = find_function(files, &decl.function) else {
591            out.push(registration_error(
592                files,
593                format!(
594                    "protocol impl `{}` for `{}`: `{}` is not a declared function",
595                    decl.protocol.protocol_name(),
596                    decl.type_name,
597                    decl.function
598                ),
599            ));
600            continue;
601        };
602        let at = |message: String| Diagnostic {
603            file,
604            range: knot.name.range,
605            code: DiagnosticCode::E115,
606            message,
607        };
608
609        // NS-A8 (docs/tower-mini-spec.md T4, issue #1114): tower kinds can
610        // NEVER implement registry protocols — `compare` would contradict
611        // the ruled not-orderable posture, and `display`/`iterate` would
612        // shadow compiler-owned behavior. Checked before (and regardless
613        // of) the STRUCT lookup, so a user STRUCT named `vec3` cannot
614        // smuggle an impl in under a tower name — tower type names are
615        // global, like `int`.
616        if crate::infer::TowerTy::from_name(&decl.type_name).is_some() {
617            out.push(Diagnostic {
618                file,
619                range: knot.name.range,
620                code: DiagnosticCode::E118,
621                message: format!(
622                    "protocol impl `{}` for `{}`: numeric-tower kinds are compiler-known and cannot implement registry protocols{}",
623                    decl.protocol.protocol_name(),
624                    decl.type_name,
625                    if decl.protocol == Protocol::Compare {
626                        " (tower values are not orderable — tower-mini-spec T4)"
627                    } else {
628                        ""
629                    }
630                ),
631            });
632            continue;
633        }
634
635        if !struct_names.contains(decl.type_name.as_str()) {
636            out.push(at(format!(
637                "protocol impl `{}` for `{}`: the type is not a declared STRUCT (only user struct types may implement registry protocols)",
638                decl.protocol.protocol_name(),
639                decl.type_name
640            )));
641            continue;
642        }
643        if !seen.insert((decl.protocol, decl.type_name.as_str())) {
644            out.push(at(format!(
645                "duplicate protocol impl: `{}` for `{}` is already registered",
646                decl.protocol.protocol_name(),
647                decl.type_name
648            )));
649            continue;
650        }
651        if let Some(message) = shape_error(decl, knot) {
652            out.push(at(message));
653            continue;
654        }
655        checked.push((decl, file, knot));
656    }
657
658    if checked.is_empty() {
659        return out;
660    }
661
662    // Contract enforcement over the inferred rows (NS-A2 substrate). One
663    // whole-project inference serves every impl, the
664    // `whole_project_diagnostics` effects posture.
665    let rows = crate::infer::effects_project(files, index, resolutions, host_manifest);
666    for (decl, file, knot) in checked {
667        let Some(def_id) = index.by_name.get(&decl.function).and_then(|ids| {
668            ids.iter()
669                .copied()
670                .find(|id| index.symbols.get(id).is_some_and(|info| info.file == file))
671        }) else {
672            continue;
673        };
674        let Some(row) = rows.get(&def_id) else {
675            continue;
676        };
677        if let Some(message) = contract_error(decl.protocol, &decl.type_name, row, index) {
678            out.push(Diagnostic {
679                file,
680                range: knot.name.range,
681                code: DiagnosticCode::E114,
682                message,
683            });
684        }
685    }
686    out
687}
688
689/// Locate a declared function knot by name across the project's files.
690fn find_function<'a>(files: &[(FileId, &'a HirFile)], name: &str) -> Option<(FileId, &'a Knot)> {
691    files.iter().find_map(|&(file, hir)| {
692        hir.knots
693            .iter()
694            .find(|k| k.is_function && k.name.text == name)
695            .map(|k| (file, k))
696    })
697}
698
699/// Signature-shape validation against the protocol's declared form. Arity
700/// and `ref`-ness are structural (always checkable); type annotations are
701/// checked only where present — an unannotated param is the gradual
702/// posture, accepted (TM-2's annotation-wins/inference-fills split).
703fn shape_error(decl: &ProtocolImplDecl, knot: &Knot) -> Option<String> {
704    let proto = decl.protocol;
705    if knot.params.len() != proto.arity() {
706        return Some(format!(
707            "protocol impl `{}` for `{}`: `{}` takes {} parameter(s), but the protocol method `{}` declares {}",
708            proto.protocol_name(),
709            decl.type_name,
710            knot.name.text,
711            knot.params.len(),
712            proto.method_name(),
713            proto.arity()
714        ));
715    }
716    for (i, param) in knot.params.iter().enumerate() {
717        let want_ref = i == 0 && proto.receiver_is_ref();
718        if param.is_ref != want_ref {
719            return Some(format!(
720                "protocol impl `{}` for `{}`: parameter `{}` must {} `ref` (the protocol method is `{}`)",
721                proto.protocol_name(),
722                decl.type_name,
723                param.name.text,
724                if want_ref { "be" } else { "not be" },
725                signature_phrase(proto),
726            ));
727        }
728        // Receiver params (all of display's/next's, both of compare's)
729        // must be the implementing type where annotated.
730        if let Some(TypeExpr::Named { name, .. }) = &param.annotation
731            && name != &decl.type_name
732        {
733            return Some(format!(
734                "protocol impl `{}` for `{}`: parameter `{}` is annotated `{}`, but the receiver of a protocol impl must be the implementing type",
735                proto.protocol_name(),
736                decl.type_name,
737                param.name.text,
738                name
739            ));
740        }
741    }
742    let want_return = match proto {
743        Protocol::Display => Some("string"),
744        Protocol::Compare => Some("int"),
745        // `next` returns `Option[T]` — not expressible in the TM-2
746        // annotation grammar yet, so no return check v1.
747        Protocol::Iterate => None,
748    };
749    if let (Some(want), Some(TypeExpr::Named { name, .. })) = (want_return, &knot.return_type)
750        && name != want
751    {
752        return Some(format!(
753            "protocol impl `{}` for `{}`: return type is annotated `{}`, but `{}` returns `{}`",
754            proto.protocol_name(),
755            decl.type_name,
756            name,
757            signature_phrase(proto),
758            want
759        ));
760    }
761    None
762}
763
764fn signature_phrase(proto: Protocol) -> &'static str {
765    match proto {
766        Protocol::Display => "display(self: T): string",
767        Protocol::Compare => "compare(a: T, b: T): int",
768        Protocol::Iterate => "next(ref self): Option[T]",
769    }
770}
771
772/// The per-protocol effect contract (stdlib-spec §9.6), enforced over the
773/// inferred row. Every v1 contract bounds the **global** row at empty
774/// (see [`Protocol::receiver_is_ref`] for why `next`'s receiver write is
775/// invisible here): no global reads — `display` runs at deferred
776/// transcript-resolution time, after story state may have moved on, so a
777/// state-reading impl would render differently at read time than at emit
778/// time — no writes, no external calls, no emits, no tags, no faults, and
779/// never opaque.
780fn contract_error(
781    proto: Protocol,
782    type_name: &str,
783    row: &EffectRow,
784    index: &SymbolIndex,
785) -> Option<String> {
786    // Bool-granularity carve-out (v1): `next`'s mandatory `ref` receiver
787    // makes NS-A2's inference mark EVERY iterate impl as conservatively
788    // faulting (a `ref` param's deref can raise `ProjectionInvalidated`,
789    // charged to the callee — `infer::body`'s ref-param rule), so
790    // enforcing the `total` leg would reject every possible impl. Until
791    // the reserved per-fault-kind row refinement can tell the sanctioned
792    // receiver-deref fault from a real domain fault, iterate's contract
793    // skips the `faults` dimension — under-enforcement, chosen over a
794    // dead protocol, and called out in the registry docs.
795    //
796    // NS-A4 / **F29(a)** (ruled by delegation 2026-07-19, stdlib-spec §4b
797    // — the symmetric carve-out, the post-A3 composition audit's C1/C2
798    // finding): `display`/`compare` are judged on the **refined** faults
799    // bit, not the conservative one. An impl whose row is provably total
800    // — every charge site discharged by local type evidence
801    // (`EffectRow::faults_refined`, invariant `refined → conservative`) —
802    // does NOT inherit the conservative bit; the conservative union
803    // applies only when the impl's own row is opaque (already a contract
804    // violation above) or genuinely fault-bearing.
805    let faults_exceed = row.faults_refined && !matches!(proto, Protocol::Iterate);
806    if !row.is_pessimal()
807        && row.reads.is_empty()
808        && row.writes.is_empty()
809        && row.calls.is_empty()
810        && !row.emits
811        && !row.tags
812        && !faults_exceed
813    {
814        return None;
815    }
816    let mut parts = Vec::new();
817    if row.is_pessimal() {
818        parts.push(
819            "calls through a function value or unresolved callee (unbounded row)".to_string(),
820        );
821    }
822    let name_of = |id: &brink_format::DefinitionId| {
823        index
824            .symbols
825            .get(id)
826            .map_or_else(|| format!("{id:?}"), |info| info.name.clone())
827    };
828    if !row.reads.is_empty() {
829        let names: Vec<String> = row.reads.iter().map(name_of).collect();
830        parts.push(format!("reads {}", names.join(", ")));
831    }
832    if !row.writes.is_empty() {
833        let names: Vec<String> = row.writes.iter().map(name_of).collect();
834        parts.push(format!("writes {}", names.join(", ")));
835    }
836    if !row.calls.is_empty() {
837        let names: Vec<String> = row.calls.iter().cloned().collect();
838        parts.push(format!("calls {}", names.join(", ")));
839    }
840    if row.emits {
841        parts.push("emits content".to_string());
842    }
843    if row.tags {
844        parts.push("touches the tag channel".to_string());
845    }
846    if faults_exceed {
847        parts.push("can raise a turn-terminating fault".to_string());
848    }
849    Some(format!(
850        "protocol impl `{}` for `{type_name}` exceeds the {} contract: {}",
851        proto.protocol_name(),
852        proto.contract_phrase(),
853        parts.join("; ")
854    ))
855}
856
857fn registration_error(files: &[(FileId, &HirFile)], message: String) -> Diagnostic {
858    Diagnostic {
859        file: files.first().map_or(FileId(0), |&(f, _)| f),
860        range: rowan::TextRange::empty(0.into()),
861        code: DiagnosticCode::E115,
862        message,
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use brink_ir::SymbolManifest;
869    use brink_ir::hir::HirFile;
870
871    use super::*;
872
873    fn lower(src: &str) -> (HirFile, SymbolManifest) {
874        let parsed = brink_syntax::parse(src);
875        let tree = parsed.tree();
876        let (hir, manifest, diags) = brink_ir::hir::lower(FileId(0), &tree);
877        assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
878        (hir, manifest)
879    }
880
881    fn reserved_diags(src: &str) -> Vec<Diagnostic> {
882        let (hir, _manifest) = lower(src);
883        check_reserved_names(&[(FileId(0), &hir)])
884    }
885
886    /// Native-frontend twin of [`reserved_diags`] — `Expr::Lambda` is minted
887    /// only by `hir::lower_native` (the ink/brink-compat frontend has no
888    /// lambda grammar), so a fixture exercising a lambda param must go
889    /// through the native parser, mirroring `coalesce.rs`/
890    /// `comparator_contract.rs`'s `build_native` test helpers.
891    fn reserved_diags_native(src: &str) -> Vec<Diagnostic> {
892        let parse = brink_syntax_native::parse(src);
893        assert!(
894            parse.errors().is_empty(),
895            "fixture must parse cleanly: {:?}",
896            parse.errors()
897        );
898        let tree = parse.tree();
899        let (hir, _manifest, diags) = brink_ir::hir::lower_native::lower(FileId(0), &tree);
900        assert!(diags.is_empty(), "lowering diagnostics: {diags:?}");
901        check_reserved_names(&[(FileId(0), &hir)])
902    }
903
904    fn impl_diags(src: &str, impls: &[ProtocolImplDecl]) -> Vec<Diagnostic> {
905        let (hir, manifest) = lower(src);
906        let result = crate::analyze(&[(FileId(0), &hir, &manifest)]);
907        check_protocol_impls(
908            &[(FileId(0), &hir)],
909            &result.index,
910            &result.resolutions,
911            None,
912            impls,
913        )
914    }
915
916    fn decl(protocol: Protocol, type_name: &str, function: &str) -> ProtocolImplDecl {
917        ProtocolImplDecl {
918            protocol,
919            type_name: type_name.to_string(),
920            function: function.to_string(),
921        }
922    }
923
924    const POINT: &str = "STRUCT Point = #{\n    x: float,\n    y: float,\n}\n";
925
926    // ─── E113: reserved names (F6) ──────────────────────────────────
927
928    #[test]
929    fn knot_named_display_is_reserved() {
930        let diags = reserved_diags("== display ==\nHello.\n-> DONE\n");
931        assert_eq!(diags.len(), 1, "{diags:?}");
932        assert_eq!(diags[0].code, DiagnosticCode::E113);
933    }
934
935    #[test]
936    fn function_named_compare_is_reserved() {
937        let diags = reserved_diags("=== function compare(a, b) ===\n~ return 0\n");
938        assert_eq!(diags.len(), 1, "{diags:?}");
939        assert_eq!(diags[0].code, DiagnosticCode::E113);
940    }
941
942    #[test]
943    fn stitch_named_next_is_reserved() {
944        let diags = reserved_diags("== knot ==\n= next\nHello.\n-> DONE\n");
945        assert_eq!(diags.len(), 1, "{diags:?}");
946        assert_eq!(diags[0].code, DiagnosticCode::E113);
947    }
948
949    #[test]
950    fn var_const_external_named_reserved() {
951        let diags = reserved_diags("VAR display = 1\nCONST compare = 2\nEXTERNAL next(x)\n");
952        assert_eq!(diags.len(), 3, "{diags:?}");
953        assert!(diags.iter().all(|d| d.code == DiagnosticCode::E113));
954    }
955
956    #[test]
957    fn param_named_display_is_reserved() {
958        let diags = reserved_diags("=== function f(display) ===\n~ return display\n");
959        assert_eq!(diags.len(), 1, "{diags:?}");
960        assert_eq!(diags[0].code, DiagnosticCode::E113);
961    }
962
963    #[test]
964    fn temp_and_for_var_in_logic_block_are_reserved() {
965        let src = "== k ==\n~ {\n    temp next = 1\n    for display in #[1, 2] {\n        next = next + display\n    }\n}\n-> DONE\n";
966        let diags = reserved_diags(src);
967        assert_eq!(diags.len(), 2, "{diags:?}");
968        assert!(diags.iter().all(|d| d.code == DiagnosticCode::E113));
969    }
970
971    #[test]
972    fn weave_level_temp_named_next_is_reserved() {
973        let diags = reserved_diags("== k ==\n~ temp next = 1\n{next}\n-> DONE\n");
974        assert_eq!(diags.len(), 1, "{diags:?}");
975        assert_eq!(diags[0].code, DiagnosticCode::E113);
976    }
977
978    #[test]
979    fn list_members_and_type_names_are_not_reserved() {
980        // Deliberate carve-outs (see `check_reserved_names`'s doc): LIST
981        // members are value-position narrative vocabulary; LIST/STRUCT
982        // *type* names aren't callable.
983        let diags = reserved_diags("LIST steps = intro, next, outro\n");
984        assert!(diags.is_empty(), "{diags:?}");
985    }
986
987    #[test]
988    fn lambda_param_named_display_is_reserved() {
989        // Issue #1773: same shadowing shape as `param_named_display_is_reserved`
990        // (a top-level fn/knot/stitch param), but the binding site is a
991        // lambda's own `|…|` param row instead. Same name, same file — must
992        // get the identical E113 answer.
993        let diags = reserved_diags_native("var f = |display| display\n");
994        assert_eq!(diags.len(), 1, "{diags:?}");
995        assert_eq!(diags[0].code, DiagnosticCode::E113);
996    }
997
998    #[test]
999    fn lambda_param_named_display_in_choice_label_is_reserved() {
1000        // Issue #1773 review finding: native choice labels (`* Gold: {…}`)
1001        // lower interpolations into `choice.start_content` /
1002        // `bracket_content` / `inner_content`, NOT into `choice.body.stmts`
1003        // — so a lambda param shadow reachable only through one of those
1004        // three `Content` regions was still unreached without walking them.
1005        let diags =
1006            reserved_diags_native("flow f() {\n  {?\n    * Gold: {fmt(|display| 0)}\n  }\n}\n");
1007        assert_eq!(diags.len(), 1, "{diags:?}");
1008        assert_eq!(diags[0].code, DiagnosticCode::E113);
1009    }
1010
1011    #[test]
1012    fn ordinary_names_stay_clean() {
1013        let diags = reserved_diags(
1014            "VAR score = 1\n== k ==\n~ temp shown = score\n{shown}\n-> DONE\n=== function render(p) ===\n~ return \"x\"\n",
1015        );
1016        assert!(diags.is_empty(), "{diags:?}");
1017    }
1018
1019    // ─── E115: impl shape validation ────────────────────────────────
1020
1021    #[test]
1022    fn well_formed_display_impl_is_clean() {
1023        let src = format!("{POINT}=== function render(p: Point): string ===\n~ return \"P\"\n");
1024        let diags = impl_diags(&src, &[decl(Protocol::Display, "Point", "render")]);
1025        assert!(diags.is_empty(), "{diags:?}");
1026    }
1027
1028    #[test]
1029    fn unknown_function_is_e115() {
1030        let diags = impl_diags(POINT, &[decl(Protocol::Display, "Point", "nope")]);
1031        assert_eq!(diags.len(), 1, "{diags:?}");
1032        assert_eq!(diags[0].code, DiagnosticCode::E115);
1033        assert!(diags[0].message.contains("not a declared function"));
1034    }
1035
1036    #[test]
1037    fn non_struct_type_is_e115() {
1038        let src = "=== function render(p) ===\n~ return \"x\"\n";
1039        let diags = impl_diags(src, &[decl(Protocol::Display, "Point", "render")]);
1040        assert_eq!(diags.len(), 1, "{diags:?}");
1041        assert_eq!(diags[0].code, DiagnosticCode::E115);
1042        assert!(diags[0].message.contains("not a declared STRUCT"));
1043    }
1044
1045    #[test]
1046    fn wrong_arity_is_e115() {
1047        let src = format!("{POINT}=== function render(p, extra) ===\n~ return \"x\"\n");
1048        let diags = impl_diags(&src, &[decl(Protocol::Display, "Point", "render")]);
1049        assert_eq!(diags.len(), 1, "{diags:?}");
1050        assert_eq!(diags[0].code, DiagnosticCode::E115);
1051        assert!(diags[0].message.contains("parameter"));
1052    }
1053
1054    #[test]
1055    fn display_receiver_must_not_be_ref() {
1056        let src = format!("{POINT}=== function render(ref p) ===\n~ return \"x\"\n");
1057        let diags = impl_diags(&src, &[decl(Protocol::Display, "Point", "render")]);
1058        assert_eq!(diags.len(), 1, "{diags:?}");
1059        assert_eq!(diags[0].code, DiagnosticCode::E115);
1060    }
1061
1062    #[test]
1063    fn next_receiver_must_be_ref() {
1064        let src = format!("{POINT}=== function step(p) ===\n~ return 0\n");
1065        let diags = impl_diags(&src, &[decl(Protocol::Iterate, "Point", "step")]);
1066        assert_eq!(diags.len(), 1, "{diags:?}");
1067        assert_eq!(diags[0].code, DiagnosticCode::E115);
1068        assert!(diags[0].message.contains("ref"));
1069    }
1070
1071    #[test]
1072    fn contradicting_param_annotation_is_e115() {
1073        let src = format!("{POINT}=== function render(p: int) ===\n~ return \"x\"\n");
1074        let diags = impl_diags(&src, &[decl(Protocol::Display, "Point", "render")]);
1075        assert_eq!(diags.len(), 1, "{diags:?}");
1076        assert_eq!(diags[0].code, DiagnosticCode::E115);
1077        assert!(diags[0].message.contains("annotated"));
1078    }
1079
1080    #[test]
1081    fn contradicting_return_annotation_is_e115() {
1082        let src =
1083            format!("{POINT}=== function cmp(a: Point, b: Point): string ===\n~ return \"x\"\n");
1084        let diags = impl_diags(&src, &[decl(Protocol::Compare, "Point", "cmp")]);
1085        assert_eq!(diags.len(), 1, "{diags:?}");
1086        assert_eq!(diags[0].code, DiagnosticCode::E115);
1087        assert!(diags[0].message.contains("return"));
1088    }
1089
1090    #[test]
1091    fn duplicate_registration_is_e115() {
1092        let src = format!(
1093            "{POINT}=== function render(p) ===\n~ return \"x\"\n=== function render2(p) ===\n~ return \"y\"\n"
1094        );
1095        let diags = impl_diags(
1096            &src,
1097            &[
1098                decl(Protocol::Display, "Point", "render"),
1099                decl(Protocol::Display, "Point", "render2"),
1100            ],
1101        );
1102        assert_eq!(diags.len(), 1, "{diags:?}");
1103        assert_eq!(diags[0].code, DiagnosticCode::E115);
1104        assert!(diags[0].message.contains("duplicate"));
1105    }
1106
1107    // ─── E118: tower kinds can never implement protocols (NS-A8) ────
1108
1109    #[test]
1110    fn compare_for_tower_kind_is_e118() {
1111        // T4 (docs/tower-mini-spec.md): the tower is NOT orderable —
1112        // registering `compare` for a tower kind must be impossible.
1113        let src = "=== function cmp(a, b) ===\n~ return 0\n";
1114        for kind in ["vec2", "vec3", "vec4", "quat", "mat2", "mat3", "mat4"] {
1115            let diags = impl_diags(src, &[decl(Protocol::Compare, kind, "cmp")]);
1116            assert_eq!(diags.len(), 1, "{kind}: {diags:?}");
1117            assert_eq!(diags[0].code, DiagnosticCode::E118, "{kind}");
1118            assert!(diags[0].message.contains("not orderable"), "{kind}");
1119        }
1120    }
1121
1122    #[test]
1123    fn display_and_iterate_for_tower_kind_are_e118() {
1124        let src = "=== function render(p) ===\n~ return \"x\"\n";
1125        for proto in [Protocol::Display, Protocol::Iterate] {
1126            let diags = impl_diags(src, &[decl(proto, "vec3", "render")]);
1127            assert_eq!(diags.len(), 1, "{proto:?}: {diags:?}");
1128            assert_eq!(diags[0].code, DiagnosticCode::E118, "{proto:?}");
1129        }
1130    }
1131
1132    #[test]
1133    fn tower_rejection_wins_over_a_shadowing_struct() {
1134        // A user STRUCT named `vec3` cannot smuggle a compare impl in
1135        // under the tower name — tower type names are global, like `int`.
1136        let src = "STRUCT vec3 = #{\n    v: float,\n}\n=== function cmp(a, b) ===\n~ return 0\n";
1137        let diags = impl_diags(src, &[decl(Protocol::Compare, "vec3", "cmp")]);
1138        assert_eq!(diags.len(), 1, "{diags:?}");
1139        assert_eq!(diags[0].code, DiagnosticCode::E118);
1140    }
1141
1142    // ─── E114: effect-contract enforcement (needs NS-A2's rows) ─────
1143
1144    #[test]
1145    fn global_write_exceeds_display_contract() {
1146        let src = format!(
1147            "{POINT}VAR seen = 0\n=== function render(p) ===\n~ seen = seen + 1\n~ return \"x\"\n"
1148        );
1149        let diags = impl_diags(&src, &[decl(Protocol::Display, "Point", "render")]);
1150        assert_eq!(diags.len(), 1, "{diags:?}");
1151        assert_eq!(diags[0].code, DiagnosticCode::E114);
1152        assert!(
1153            diags[0].message.contains("writes seen"),
1154            "{}",
1155            diags[0].message
1156        );
1157    }
1158
1159    #[test]
1160    fn global_read_exceeds_display_contract() {
1161        // Display runs at deferred transcript-resolution time — a
1162        // state-reading impl would render differently at read time than
1163        // at emit time, so reads are outside the contract too.
1164        let src = format!("{POINT}VAR mood = 1\n=== function render(p) ===\n~ return mood\n");
1165        let diags = impl_diags(&src, &[decl(Protocol::Display, "Point", "render")]);
1166        assert_eq!(diags.len(), 1, "{diags:?}");
1167        assert_eq!(diags[0].code, DiagnosticCode::E114);
1168        assert!(
1169            diags[0].message.contains("reads mood"),
1170            "{}",
1171            diags[0].message
1172        );
1173    }
1174
1175    #[test]
1176    fn emitting_impl_exceeds_silent() {
1177        let src = format!("{POINT}=== function render(p) ===\nLoud line.\n~ return \"x\"\n");
1178        let diags = impl_diags(&src, &[decl(Protocol::Display, "Point", "render")]);
1179        assert_eq!(diags.len(), 1, "{diags:?}");
1180        assert_eq!(diags[0].code, DiagnosticCode::E114);
1181        assert!(diags[0].message.contains("emits"), "{}", diags[0].message);
1182    }
1183
1184    #[test]
1185    fn faulting_impl_exceeds_total() {
1186        // `min` over a *float* array carries the §4b ordering fault
1187        // unconditionally (mode-independent rows: dev NaN-fault / prod
1188        // pinned order — the checker doesn't know modes exist), so the
1189        // charge is NOT discharged (F29's carve-out only covers provably
1190        // NaN-free element types) and breaks the `total` leg.
1191        let src = format!(
1192            "{POINT}=== function cmp(a, b) ===\n~ temp lowest = min(#[1.0, 2.0])\n~ return 0\n"
1193        );
1194        let diags = impl_diags(&src, &[decl(Protocol::Compare, "Point", "cmp")]);
1195        assert_eq!(diags.len(), 1, "{diags:?}");
1196        assert_eq!(diags[0].code, DiagnosticCode::E114);
1197        assert!(diags[0].message.contains("fault"), "{}", diags[0].message);
1198    }
1199
1200    // ─── F29(a) — the symmetric faults carve-out (ruled by delegation
1201    // 2026-07-19, stdlib-spec §4b): a display/compare impl whose inferred
1202    // row is PROVABLY total does not inherit the conservative faults bit;
1203    // the conservative union applies only when the impl's own row is
1204    // opaque or genuinely fault-bearing. ─────────────────────────────────
1205
1206    #[test]
1207    fn f29_provably_total_impl_is_not_rejected_for_conservative_faults() {
1208        // `min(#[1, 2])`/`len(#[1, 2])` carry the *conservative* faults
1209        // bit (bool v1 — the wrong-type/NotOrderable paths exist in
1210        // general) but are provably total here: int-array arguments
1211        // discharge the charge (F29), so the impl's refined row is
1212        // faults-free and E114 must NOT fire.
1213        let src = format!(
1214            "{POINT}=== function cmp(a, b) ===\n~ temp lowest = min(#[1, 2])\n~ temp n = len(#[1, 2])\n~ return 0\n"
1215        );
1216        let diags = impl_diags(&src, &[decl(Protocol::Compare, "Point", "cmp")]);
1217        assert!(diags.is_empty(), "{diags:?}");
1218    }
1219
1220    #[test]
1221    fn f29_opaque_impl_keeps_the_conservative_union() {
1222        // A call through a function value escapes the static call graph —
1223        // the row is opaque, and F29's carve-out explicitly does NOT
1224        // apply ("the conservative union applies only when the impl's own
1225        // row is opaque or fault-bearing"). E114 names the opaque escape.
1226        let src = format!(
1227            "{POINT}=== function helper() ===\n~ return 1\n\n=== function shape(self) ===\n~ temp f = #fn(helper)\n~ temp n = call(f)\n~ return \"p\"\n"
1228        );
1229        let diags = impl_diags(&src, &[decl(Protocol::Display, "Point", "shape")]);
1230        assert_eq!(diags.len(), 1, "{diags:?}");
1231        assert_eq!(diags[0].code, DiagnosticCode::E114);
1232    }
1233
1234    #[test]
1235    fn f29_value_dependent_fault_still_rejects() {
1236        // Indexing is value-dependent (OOB) — never discharged; the
1237        // refined bit stays set and the contract still rejects.
1238        let src = format!(
1239            "{POINT}=== function cmp(a, b) ===\n~ temp arr = #[1, 2]\n~ temp x = arr[5]\n~ return 0\n"
1240        );
1241        let diags = impl_diags(&src, &[decl(Protocol::Compare, "Point", "cmp")]);
1242        assert_eq!(diags.len(), 1, "{diags:?}");
1243        assert_eq!(diags[0].code, DiagnosticCode::E114);
1244        assert!(diags[0].message.contains("fault"), "{}", diags[0].message);
1245    }
1246
1247    #[test]
1248    fn pure_compare_impl_is_clean() {
1249        let src = format!("{POINT}=== function cmp(a: Point, b: Point): int ===\n~ return 0\n");
1250        let diags = impl_diags(&src, &[decl(Protocol::Compare, "Point", "cmp")]);
1251        assert!(diags.is_empty(), "{diags:?}");
1252    }
1253
1254    #[test]
1255    fn pure_next_impl_with_ref_receiver_is_clean() {
1256        // The `ref` receiver marks the row as conservatively faulting
1257        // (`ProjectionInvalidated` — infer::body's ref-param rule); the
1258        // iterate contract's bool-granularity carve-out must not reject
1259        // the only shape an impl can legally have.
1260        let src =
1261            format!("{POINT}=== function step(ref p) ===\n~ p.x = p.x + 1.0\n~ return some(p.x)\n");
1262        let diags = impl_diags(&src, &[decl(Protocol::Iterate, "Point", "step")]);
1263        assert!(diags.is_empty(), "{diags:?}");
1264    }
1265
1266    #[test]
1267    fn next_impl_writing_a_global_still_exceeds() {
1268        // The faults carve-out is faults-only: a global write inside a
1269        // `next` impl is outside writes-receiver·silent·total regardless.
1270        let src = format!(
1271            "{POINT}VAR steps = 0\n=== function step(ref p) ===\n~ steps = steps + 1\n~ return some(p.x)\n"
1272        );
1273        let diags = impl_diags(&src, &[decl(Protocol::Iterate, "Point", "step")]);
1274        assert_eq!(diags.len(), 1, "{diags:?}");
1275        assert_eq!(diags[0].code, DiagnosticCode::E114);
1276        assert!(
1277            diags[0].message.contains("writes steps"),
1278            "{}",
1279            diags[0].message
1280        );
1281    }
1282
1283    // ─── iterate: the closed builtin iterable set ───────────────────
1284
1285    #[test]
1286    fn iterate_element_types_cover_the_closed_set() {
1287        assert_eq!(
1288            iterate_element_ty(&Ty::Array(Box::new(Ty::Int))),
1289            Some(Ty::Int)
1290        );
1291        assert_eq!(
1292            iterate_element_ty(&Ty::Map(Box::new(Ty::String), Box::new(Ty::Int))),
1293            Some(Ty::String),
1294            "maps iterate keys"
1295        );
1296        assert_eq!(iterate_element_ty(&Ty::Int), None);
1297        assert_eq!(iterate_element_ty(&Ty::String), None);
1298        assert_eq!(iterate_element_ty(&Ty::List("Mood".into())), None);
1299    }
1300
1301    // ─── HirFile field coverage guard (issue #2784) ──────────────────
1302
1303    /// Issue #2784: [`option_conditions.rs`]'s twin guard
1304    /// (`option_conditions::tests::
1305    /// hir_file_condition_bearing_fields_stay_in_sync_with_the_e116_walk`)
1306    /// for [`check_reserved_names`] — this module's doc comment on that
1307    /// function names `protocols.rs`'s own walk as the template
1308    /// `option_conditions.rs`'s E116 walk mirrors, so both share the same
1309    /// container list and the same risk: a new `Stmt`/`Expr`-bearing
1310    /// `HirFile` field landing without a corresponding walk here.
1311    ///
1312    /// `HirFile` is a **struct**, so there is no enum-exhaustiveness match
1313    /// the compiler enforces for free the way the `classify_*` idiom does
1314    /// (#2752/#1767). The struct analogue is destructuring every field by
1315    /// name with **no `..` rest pattern**: add a field to `HirFile`
1316    /// without extending this list and the destructure below fails to
1317    /// compile (E0027, "pattern does not mention field `…`") — a
1318    /// compile-time RED rather than a runtime assertion failure, but it
1319    /// fails the gate the same way. Verified red locally by adding a dummy
1320    /// field to `HirFile` before relying on this guard (see the PR
1321    /// description); reverted before landing.
1322    ///
1323    /// Each walked field below already has a positive-control test proving
1324    /// it's *actually* reached: [`knot_named_display_is_reserved`] /
1325    /// [`stitch_named_next_is_reserved`] (`knots`),
1326    /// [`var_const_external_named_reserved`] (`variables`/`constants`,
1327    /// plus `externals`, which has no `Stmt`/`Expr` tree to walk but does
1328    /// carry a `Name` this same function reserves), and the module-level
1329    /// `e113_*` fixtures in `brink-compiler`'s own diagnostics suite that
1330    /// cover root-content declarations directly (`root_content`).
1331    #[test]
1332    fn hir_file_condition_bearing_fields_stay_in_sync_with_the_e113_walk() {
1333        let (hir, _manifest) = lower("=== main ===\nHi.\n-> DONE\n");
1334
1335        let HirFile {
1336            // Walked by `check_reserved_names`: `root_content`/`knots`
1337            // (each `Knot`, function or not, plus every `stitch`) are
1338            // walked via `walk_stmts`/`walk_expr_for_lambdas` for both
1339            // declaration sites and embedded lambda params;
1340            // `variables`/`constants`/`externals` are walked for their own
1341            // declared `Name` plus (for `variables`/`constants`) an
1342            // embedded lambda's param row.
1343            root_content: _,
1344            knots: _,
1345            variables: _,
1346            constants: _,
1347            externals: _,
1348            // No `Stmt`/`Expr` tree and no reservable `Name` of their own
1349            // — `check_reserved_names` never needs to visit them.
1350            lists: _,
1351            structs: _,
1352            includes: _,
1353            module: _,
1354            imports: _,
1355            visibility: _,
1356            was_directives: _,
1357            allow_scopes: _,
1358            element_matches: _,
1359            cue_names: _,
1360            native: _,
1361            claim_handlers: _,
1362            dispatch_handlers: _,
1363        } = hir;
1364    }
1365}