brink-analyzer 0.0.17

Cross-file semantic analysis for inkle's ink narrative scripting language
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
//! `contains(m, needle)` static key-domain warning (`E152`, issue #582,
//! companion to #580's ruling — `docs/decision-log.md` 2026-07-12
//! "contains(map, non-key-domain needle) returns false").
//!
//! #580 ruled that `contains(m, needle)` is *total*: when `m` is a map and
//! `needle` is outside the runtime key domain (int/string/bool —
//! `brink_format::MapKey::from_value`'s exact permitted set), the call
//! returns `false` rather than raising `InvalidMapKeyType`. That ruling's
//! own text deferred a **static** half: "a static analyzer warning for
//! statically-visible non-key needles is deferred (#582)". This module is
//! that half — flagging the call at compile time when both halves of the
//! always-false shape are already visible in the source, so the author
//! sees the mistake before running the story rather than discovering a
//! silently-empty membership test at runtime.
//!
//! # Why this needs the inference substrate (strict-mode-only)
//!
//! Unlike [`crate::map_keys`]'s `E106` (a map-literal key's own domain,
//! checkable from the literal's syntax alone with zero type resolution),
//! this check needs to answer two static-typing questions no purely
//! syntactic pass can: "is `m` a map at all?" (a `contains` call on an
//! *array* has no key-domain restriction — it's ordinary structural
//! element containment) and "what is `needle`'s type, when it isn't a bare
//! literal?" (a `VAR`, a local `temp`, a call result, an index expression).
//! Both questions are exactly [`crate::structs::classify_expr_ty`]'s job —
//! the same whole-project inference-substrate classifier
//! [`crate::conversions`]'s `E078` and [`crate::range_refinement`]'s `E117`
//! already reuse for their own domain checks. That substrate
//! ([`crate::infer::InferenceResult`]) is only ever computed under `types =
//! strict` (`brink_analyzer::strict_diagnostics`'s own gate), so this check
//! is wired into [`crate::strict::check`] alongside them, strict-mode-only
//! like the rest of that family. Under `types = gradual` this stays silent
//! — the runtime's total, non-faulting `false` return (#580) is already the
//! correct residual, so gradual mode loses nothing but the compile-time
//! convenience.
//!
//! # The exact shape flagged
//!
//! An **unresolved** (not author-shadowed, `docs/t1b-surface-spec.md` §5)
//! call to `contains` with exactly two arguments (a wrong arity is `E031`'s
//! job, not this pass's), where:
//!
//! 1. the first argument (`m`) classifies, via
//!    [`crate::structs::classify_expr_ty`], to a concrete `Ty::Map(_, _)`
//!    — an `Unknown`/`Conflicted` or non-`Map` (e.g. `Ty::Array`) result
//!    means "not statically known to be a map", and the call is left
//!    alone; and
//! 2. the second argument (`needle`) classifies to a concrete type outside
//!    the int/string/bool key domain — see [`non_key_domain_kind`].
//!
//! `classify_expr_ty` already reaches every shape its own family
//! (`conversions`/`range_refinement`) does: a bare literal, a `Path` to a
//! global `VAR`/`CONST` (declaration-derived, issue #1540's full-fidelity
//! `Sig::value_ty`) or to a local param/`temp` (the enclosing def's
//! finalized `BodyTypes::locals` — including one typed by the native `:
//! type` annotation grammar, issues #1487-#1489), a `Call` to a resolved
//! knot/stitch (its finalized `InferredSig::return_ty`), or an `Index`
//! (recursing into its base's array-element/map-value type). This is the
//! "far more cases" the issue's own re-scoping note anticipated for the
//! **container** and for a **global-valued needle** — see the "deliberately
//! excluded shapes" section below for the one reach `classify_expr_ty`
//! promises in general but this specific call shape can't actually use: a
//! local temp/param-valued *needle*.
//!
//! # Precision over recall: deliberately excluded shapes
//!
//! - **Any needle whose classified type is `Unknown`/`Conflicted`** —
//!   "Unknown never disagrees" (the same posture every sibling check in
//!   this family takes): an unannotated, never-narrowed param, a
//!   never-observed local, or an expression shape `classify_expr_ty`
//!   doesn't handle (field access, arithmetic, `?:`/coalesce, …) stays
//!   silently unchecked. A false "always false" claim would be strictly
//!   worse than a miss (the issue's own framing).
//! - **A container not statically known to be a map** — an `Array`
//!   receiver has no key-domain restriction at all (`contains` on an array
//!   is structural element-equality against any type, matching
//!   `collection_ops::map_contains`'s own `Value::Array` branch), and an
//!   `Unknown`/`Conflicted` receiver might resolve to either at runtime, so
//!   guessing "map" would risk a false positive against legitimate array
//!   code. Only a **provably** map-typed first argument is in scope.
//! - **A needle whose type is int/string/bool but disagrees with the
//!   map's own declared key type** (e.g. a `string` needle against a
//!   statically `Map<int, _>` receiver) is deliberately **not** flagged.
//!   #580's ruling — and this companion — is about the *general* runtime
//!   key domain (`MapKey`'s three variants), not a specific map's declared
//!   `K`; a mismatch there is a different, narrower claim (it depends on
//!   trusting the map's static `K` never diverging from its runtime
//!   contents) that risks exactly the false-positive-on-legitimate-code
//!   failure mode the issue's own text warns is worse than a miss.
//! - **`contains_value(m, v)`** (map *values*, `MapContainsValue`) is a
//!   different verb with no key-domain restriction at all — out of scope
//!   by construction (this pass only matches the literal name `contains`).
//! - **A local `temp`/param used as the *needle*** (never the container —
//!   see the tests) is structurally unreachable, discovered empirically
//!   while writing this module's own test suite: `infer::body`'s
//!   pre-existing `"contains"` arm (unrelated to this issue — it exists so
//!   `intrinsic_fault_discharged` can prove a call total) calls
//!   `self.observe(needle, key_ty)` at every `contains` call site, which
//!   *unifies* the needle-local's finalized `BodyTypes::locals` entry with
//!   the container's own key type right there. A needle-local with an
//!   independently-established different type doesn't surface as "provably
//!   mismatched" — it unifies to `Ty::Conflicted`, filtered by "Unknown
//!   never disagrees"; a needle-local with no independent type simply
//!   *becomes* the container's key type and so can never disagree with it
//!   either. This is flagged as scope discovered beyond #582, not worked
//!   around here — closing it would mean reading the needle's
//!   pre-observation type from a separate snapshot, a change to the shared
//!   inference substrate outside this issue's fence.
//! - **Gradual-mode projects** get no static signal from this pass at all
//!   (see the module doc's inference-substrate note above) — the runtime
//!   `false` return is the only residual there, unchanged from #580.
//!
//! # Shadowing
//!
//! An unresolved call to `contains` is the builtin; a call that *did*
//! resolve (an author-defined `contains` knot) is an ordinary function
//! call and is never flagged here — mirrors `conversions::check`'s own
//! shadowing rule exactly (`docs/t1b-surface-spec.md` §5's "author-defined
//! function with the same name shadows the builtin" ruling).

use std::collections::BTreeMap;

use brink_format::DefinitionId;
use brink_ir::hir::visit::{self, HirVisitor};
use brink_ir::{
    Diagnostic, DiagnosticCode, Expr, FileId, HirFile, Knot, ResolutionMap, Stitch, SymbolIndex,
    SymbolKind,
};
use rowan::TextRange;

use crate::annotations;
use crate::infer::{InferenceResult, InferredSig, Ty};
use crate::structs::{self, MistypeCtx};

/// Strict-mode-only `contains(m, needle)` key-domain checks over every call
/// in the project. Callers only reach this once `strict::config_error` has
/// confirmed `types = strict` + `dialect = brink` (mirrors
/// `conversions::check`'s/`range_refinement::check`'s own entry condition —
/// same wiring point, `strict::check`).
///
/// `index`/`inference`: the same whole-project `SymbolIndex`/
/// `InferenceResult` `strict::check` already computes for its own
/// escape/mismatch checks — this is what lets [`structs::classify_expr_ty`]
/// classify a variable/call/index-valued argument instead of only
/// literal-shaped ones (see the module doc).
#[must_use]
pub fn check(
    files: &[(FileId, &HirFile)],
    index: &SymbolIndex,
    inference: &InferenceResult,
    resolutions: &ResolutionMap,
) -> Vec<Diagnostic> {
    let globals = crate::infer::collect_globals(files, index, None);
    let mut out = Vec::new();
    for &(file, hir) in files {
        let resolution_by_range = resolution_index(resolutions, file);
        let mut v = ContainsVisitor {
            file,
            index,
            globals: &globals,
            signatures: &inference.signatures,
            bodies: &inference.bodies,
            resolution_by_range: &resolution_by_range,
            current_knot_name: None,
            knot_locals: None,
            stitch_locals: None,
            lambda_locals: Vec::new(),
            diagnostics: &mut out,
        };
        // Issue #2098: `ContainsVisitor::enter_expr` has no state that needs
        // resetting between the block tree and a file-level declaration's
        // own initializer (`locals` is already `None` at this scope, same
        // as its file-scope value) — so the shared entry point covers both
        // in one drive, and the hand-rolled `check_expr`/`expr_children`
        // mirror of `visit::visit`'s own descent this used to need is gone.
        visit::visit_with_decl_initializers(hir, &mut v);
    }
    out
}

struct ContainsVisitor<'a> {
    file: FileId,
    index: &'a SymbolIndex,
    globals: &'a BTreeMap<DefinitionId, Ty>,
    signatures: &'a BTreeMap<DefinitionId, InferredSig>,
    bodies: &'a BTreeMap<DefinitionId, crate::infer::BodyTypes>,
    resolution_by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
    /// The currently-open knot's own name — `enter_stitch` needs it to
    /// reconstruct the qualified `knot.stitch` name a stitch is indexed
    /// under. Mirrors `conversions::ConversionVisitor`'s identical field.
    current_knot_name: Option<String>,
    /// The enclosing knot's own finalized locals, set for the duration of
    /// its body (and every stitch nested inside it, until `enter_stitch`
    /// overrides it with the stitch's own).
    knot_locals: Option<&'a BTreeMap<String, Ty>>,
    /// The currently-open stitch's own finalized locals, if any — takes
    /// priority over `knot_locals` while set.
    stitch_locals: Option<&'a BTreeMap<String, Ty>>,
    /// Issue #2773: a stack of pruned-locals frames, one per currently-open
    /// lambda literal (innermost last). Mirrors
    /// `structs::ConstructionVisitor`'s identical field/hook pair exactly —
    /// see that field's own doc.
    lambda_locals: Vec<BTreeMap<String, Ty>>,
    diagnostics: &'a mut Vec<Diagnostic>,
}

impl ContainsVisitor<'_> {
    fn current_locals(&self) -> Option<&BTreeMap<String, Ty>> {
        self.lambda_locals
            .last()
            .or_else(|| self.stitch_locals.or(self.knot_locals))
    }

    /// The `DefinitionId` a knot/stitch's own name resolves to — mirrors
    /// `conversions::ConversionVisitor::knot_def_id` exactly.
    fn knot_def_id(&self, knot: &Knot) -> Option<DefinitionId> {
        let kind = knot.symbol_kind();
        annotations::def_id_for(self.index, self.file, kind, &knot.name.text)
    }
}

impl HirVisitor for ContainsVisitor<'_> {
    fn visit_exprs(&self) -> bool {
        true
    }

    fn enter_knot(&mut self, knot: &Knot) {
        self.current_knot_name = Some(knot.name.text.clone());
        self.knot_locals = self
            .knot_def_id(knot)
            .and_then(|id| self.bodies.get(&id))
            .map(|b| &b.locals);
    }

    fn exit_knot(&mut self, _knot: &Knot) {
        self.current_knot_name = None;
        self.knot_locals = None;
    }

    fn enter_stitch(&mut self, stitch: &Stitch) {
        // Stitches are indexed by qualified `knot.stitch` name — mirrors
        // `conversions::ConversionVisitor::enter_stitch` exactly.
        self.stitch_locals = self.current_knot_name.as_ref().and_then(|knot_name| {
            let qualified = format!("{knot_name}.{}", stitch.name.text);
            annotations::def_id_for(self.index, self.file, SymbolKind::Stitch, &qualified)
                .and_then(|id| self.bodies.get(&id))
                .map(|b| &b.locals)
        });
    }

    fn exit_stitch(&mut self, _stitch: &Stitch) {
        self.stitch_locals = None;
    }

    fn enter_expr(&mut self, expr: &Expr) {
        // Built from direct field projections (not `self.ctx()`) so the
        // borrow checker sees this only borrows the locals-shaped fields,
        // disjoint from the `self.diagnostics` reborrow below — see
        // `structs::ConstructionVisitor::enter_expr`'s identical comment.
        let ctx = MistypeCtx {
            index: self.index,
            globals: self.globals,
            signatures: self.signatures,
            resolution_by_range: self.resolution_by_range,
            locals: self
                .lambda_locals
                .last()
                .or_else(|| self.stitch_locals.or(self.knot_locals)),
        };
        check_call(expr, self.file, &ctx, self.diagnostics);
    }

    fn enter_lambda(&mut self, l: &brink_ir::LambdaExpr) {
        let pruned = structs::pruned_locals_for_lambda(l, self.index, self.current_locals());
        self.lambda_locals.push(pruned);
    }

    fn exit_lambda(&mut self, _l: &brink_ir::LambdaExpr) {
        self.lambda_locals.pop();
    }
}

/// If `expr` is an unresolved (builtin, not author-shadowed) call to
/// `contains` with exactly two arguments, whose first argument classifies
/// to a concrete `Ty::Map(_, _)` and whose second argument classifies to a
/// concrete out-of-key-domain type — push `E152`. See the module doc's
/// "the exact shape flagged" / "deliberately excluded shapes" sections for
/// the full rule.
fn check_call(expr: &Expr, file: FileId, ctx: &MistypeCtx<'_>, out: &mut Vec<Diagnostic>) {
    let Expr::Call(path, args) = expr else {
        return;
    };
    let [seg] = path.segments.as_slice() else {
        return;
    };
    if seg.text != "contains" {
        return;
    }
    if ctx.resolution_by_range.contains_key(&range_key(path.range)) {
        return; // resolved to an author-defined symbol — shadows the builtin
    }
    let [container, needle] = args.as_slice() else {
        return; // wrong arity — E031's job, not this pass's
    };
    let Some(Ty::Map(_, _)) = structs::classify_expr_ty(container, ctx) else {
        return; // not statically known to be a map
    };
    let Some(kind) = non_key_domain_kind(needle, ctx) else {
        return;
    };
    out.push(Diagnostic {
        file,
        range: path.range,
        message: format!(
            "{}: the needle is a statically-known `{kind}` value, which is outside the \
             int/string/bool key domain — `contains` on this map always returns `false` \
             (docs/decision-log.md 2026-07-12 ruling, issue #580)",
            DiagnosticCode::E152.title(),
        ),
        code: DiagnosticCode::E152,
    });
}

/// Classify `expr` as statically outside the map key domain
/// (int/string/bool) — [`literal_non_key_domain_kind`]'s direct-literal
/// classification first (the three shapes [`structs::classify_expr_ty`]
/// doesn't itself resolve: a divert-target literal, a `LIST` literal, an
/// `#fn(...)` literal), falling back to [`structs::classify_expr_ty`]'s
/// inference-substrate classification (which already covers the scalar/
/// array/map/struct literal shapes plus `Path`/`Call`/`Index`) mapped
/// through [`non_key_domain_kind_for_ty`]. `None` — "in-domain or not
/// classifiable" — for int/string/bool, `Unknown`/`Conflicted`, or any
/// expression shape neither classifier reaches: the same "Unknown never
/// disagrees" posture every sibling check in this family takes.
fn non_key_domain_kind(expr: &Expr, ctx: &MistypeCtx<'_>) -> Option<&'static str> {
    if let Some(kind) = literal_non_key_domain_kind(expr) {
        return Some(kind);
    }
    let ty = structs::classify_expr_ty(expr, ctx)?;
    non_key_domain_kind_for_ty(&ty)
}

/// The three literal shapes [`structs::classify_expr_ty`] doesn't classify
/// on its own (it has no `Ty::Divert`/`Ty::List`/`Ty::Fn` literal arm —
/// only `Path`/`Call`/`Index` reach those, through a *resolved* symbol).
/// Mirrors [`crate::map_keys::non_key_domain_kind`]'s own label vocabulary
/// for message consistency between the two "outside the key domain" checks
/// (`E106`'s literal-key sibling and this one).
fn literal_non_key_domain_kind(expr: &Expr) -> Option<&'static str> {
    match expr {
        Expr::DivertTarget(_) => Some("divert target"),
        Expr::ListLiteral(_) => Some("list"),
        Expr::FnLiteral(_) => Some("function"),
        _ => None,
    }
}

/// Map a resolved [`Ty`] to its `E152` "kind" word — every concrete type
/// outside `Int`/`String`/`Bool` (the exact `MapKey::from_value` domain,
/// `brink_format::value::MapKey`), `None` for the permitted domain itself
/// and for `Unknown`/`Conflicted` (not statically classifiable at all).
fn non_key_domain_kind_for_ty(ty: &Ty) -> Option<&'static str> {
    match ty {
        Ty::Float => Some("float"),
        Ty::Divert => Some("divert target"),
        Ty::List(_) => Some("list"),
        Ty::Array(_) => Some("array"),
        Ty::Map(_, _) => Some("map"),
        Ty::Struct(_) => Some("struct"),
        Ty::Fn(..) => Some("function"),
        Ty::Handle(_) => Some("handle"),
        Ty::Option(_) => Some("option"),
        Ty::Range { .. } => Some("range"),
        Ty::Weighted(_) => Some("weighted"),
        Ty::Tower(_) => Some("tower"),
        // Content (issue #1846) is as far outside `MapKey::from_value`'s
        // domain as any other non-scalar leaf — a fragment-backed value has
        // no map-key representation.
        Ty::Content => Some("content"),
        // The permitted key domain itself (`Int`/`String`/`Bool`) and
        // "not statically classifiable at all" (`Unknown`/`Conflicted`)
        // both resolve to "don't flag" — distinct *reasons*, identical
        // outcome, so clippy's `match_same_arms` wants them merged.
        Ty::Int | Ty::String | Ty::Bool | Ty::Unknown | Ty::Conflicted => None,
    }
}

fn range_key(range: TextRange) -> (u32, u32) {
    (range.start().into(), range.end().into())
}

/// This file's own reference resolutions, projected to a range-keyed lookup
/// — mirrors `strict::resolution_index`/`conversions::resolution_index`
/// (same rationale: a `Path`'s range is only unique within its own file).
fn resolution_index(
    resolutions: &ResolutionMap,
    file: FileId,
) -> BTreeMap<(u32, u32), DefinitionId> {
    resolutions
        .iter()
        .filter(|r| r.file == file)
        .map(|r| (range_key(r.range), r.target))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use brink_ir::hir::lower;

    /// Computes real resolutions and a whole-project `InferenceResult` —
    /// needed by every test, since `check` always consults that substrate
    /// for its container/needle classification. Mirrors
    /// `conversions::tests::build_with_inference` exactly.
    fn build_with_inference(src: &str) -> (HirFile, SymbolIndex, ResolutionMap, InferenceResult) {
        let parsed = brink_syntax::parse(src);
        let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
        let (resolutions, _diag) =
            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
        let inference = crate::infer_project(
            &[(FileId(0), &hir)],
            &index,
            &resolutions,
            None,
            &BTreeMap::new(),
        );
        (hir, (*index).clone(), (*resolutions).clone(), inference)
    }

    fn check_all(src: &str) -> Vec<Diagnostic> {
        let (hir, index, resolutions, inference) = build_with_inference(src);
        check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
    }

    /// [`check_all`]'s native-surface twin — lambdas exist only on the
    /// native surface, so the #1764 fixtures below need `lower_native`.
    fn check_all_native(src: &str) -> Vec<Diagnostic> {
        let parsed = brink_syntax_native::parse(src);
        assert!(parsed.errors().is_empty(), "{:?}", parsed.errors());
        let (hir, manifest, _diag) = brink_ir::hir::lower_native::lower(FileId(0), &parsed.tree());
        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
        let (resolutions, _diag) =
            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
        let inference = crate::infer_project(
            &[(FileId(0), &hir)],
            &index,
            &resolutions,
            None,
            &BTreeMap::new(),
        );
        check(&[(FileId(0), &hir)], &index, &inference, &resolutions)
    }

    // ── issue #1764: a lambda's statements in a VAR/CONST initializer ────

    /// Coverage for a lambda's statements in a VAR/CONST initializer comes
    /// from `visit::visit_with_decl_initializers` (which reaches the
    /// initializer at all) composed with `walk_expr`'s `Expr::Lambda` arm
    /// (which already descends a lambda's statements) — there is no
    /// separate hand-rolled recursion for this position (issue #2098).
    #[test]
    fn an_always_false_contains_in_a_lambda_statement_of_a_var_initializer_is_e152() {
        let diags = check_all_native(
            "var f = ||: int {\n  let hit = contains(Map { 1: \"a\" }, 3.5);\n  0\n};\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E152);
        assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
    }

    /// The tail position was already covered — pinned so a later refactor
    /// can't trade one half of the body for the other.
    #[test]
    fn an_always_false_contains_in_a_lambda_tail_of_a_var_initializer_is_still_e152() {
        let diags = check_all_native(
            "var f = ||: bool {\n  let a = 1;\n  contains(Map { 1: \"a\" }, 3.5)\n};\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E152);
    }

    // ── literal needle, literal map container ───────────────────────────

    #[test]
    fn float_needle_against_a_map_literal_is_e152() {
        let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, 3.5)\n-> DONE\n");
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E152);
        assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
    }

    #[test]
    fn array_needle_against_a_map_literal_is_e152() {
        let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, #[1, 2])\n-> DONE\n");
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(diags[0].message.contains("array"), "{:?}", diags[0].message);
    }

    #[test]
    fn map_needle_against_a_map_literal_is_e152() {
        let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, #{2: \"b\"})\n-> DONE\n");
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(diags[0].message.contains("map"), "{:?}", diags[0].message);
    }

    #[test]
    fn struct_needle_against_a_map_literal_is_e152() {
        let diags = check_all(
            "STRUCT Point = #{x: int}\n\
             === main ===\n~ x = contains(#{1: \"a\"}, Point#{x: 1})\n-> DONE\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(
            diags[0].message.contains("struct"),
            "{:?}",
            diags[0].message
        );
    }

    #[test]
    fn list_needle_against_a_map_literal_is_e152() {
        let diags = check_all(
            "LIST Colors = red, blue\n\
             === main ===\n~ x = contains(#{1: \"a\"}, (red))\n-> DONE\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(diags[0].message.contains("list"), "{:?}", diags[0].message);
    }

    #[test]
    fn divert_target_needle_against_a_map_literal_is_e152() {
        let diags = check_all(
            "=== main ===\n~ x = contains(#{1: \"a\"}, -> other)\n-> DONE\n\
             === other ===\n-> DONE\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(
            diags[0].message.contains("divert target"),
            "{:?}",
            diags[0].message
        );
    }

    #[test]
    fn fn_literal_needle_against_a_map_literal_is_e152() {
        let diags = check_all(
            "=== main ===\n~ x = contains(#{1: \"a\"}, #fn(score))\n-> DONE\n\
             === score(x) ===\n~ return x\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(
            diags[0].message.contains("function"),
            "{:?}",
            diags[0].message
        );
    }

    // ── in-domain needles: never flagged ─────────────────────────────────

    #[test]
    fn int_needle_is_clean() {
        let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, 2)\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    #[test]
    fn string_needle_is_clean() {
        let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, \"k\")\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    #[test]
    fn bool_needle_is_clean() {
        let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, true)\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    /// A needle in the key domain (`string`) but disagreeing with the map's
    /// own statically-declared key type (`int`) — deliberately NOT flagged,
    /// see the module doc's "deliberately excluded shapes" section.
    #[test]
    fn key_domain_needle_of_a_mismatched_map_key_type_is_clean() {
        let diags =
            check_all("=== main ===\n~ x = contains(#{1: \"a\"}, \"not-an-int\")\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    // ── container must be provably a map ─────────────────────────────────

    #[test]
    fn float_needle_against_an_array_container_is_clean() {
        // Arrays have no key-domain restriction — element containment is
        // structural equality against any type.
        let diags = check_all("=== main ===\n~ x = contains(#[1, 2], 3.5)\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    #[test]
    fn float_needle_against_an_unknown_container_is_clean() {
        let diags = check_all("=== main(m) ===\n~ x = contains(m, 3.5)\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    // ── global VAR/CONST container and needle (issue #1540 reachability) ─

    #[test]
    fn global_map_var_container_with_float_needle_is_e152() {
        let diags = check_all(
            "VAR scores = #{1: \"a\"}\n=== main ===\n~ x = contains(scores, 3.5)\n-> DONE\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
    }

    #[test]
    fn global_const_map_container_with_array_needle_is_e152() {
        let diags = check_all(
            "CONST lookup = #{1: \"a\"}\n\
             === main ===\n~ x = contains(lookup, #[1])\n-> DONE\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(diags[0].message.contains("array"), "{:?}", diags[0].message);
    }

    #[test]
    fn global_float_var_needle_against_a_map_literal_is_e152() {
        let diags =
            check_all("VAR f = 3.5\n=== main ===\n~ x = contains(#{1: \"a\"}, f)\n-> DONE\n");
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
    }

    #[test]
    fn global_int_var_needle_is_clean() {
        let diags = check_all("VAR i = 5\n=== main ===\n~ x = contains(#{1: \"a\"}, i)\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    // ── local temp/param NEEDLE: structurally unreachable, by design ─────
    //
    // Unlike `conversions`/`range_refinement`'s own local-temp fixtures, a
    // local `temp`/param used as `contains`'s *needle* (never its
    // container) can never be caught provably-mismatched here — discovered
    // empirically while writing this suite, not a gap introduced by this
    // pass. `infer::body`'s own pre-existing `"contains"` arm (unrelated to
    // this issue — it exists to let `intrinsic_fault_discharged` prove a
    // call total) calls `self.observe(needle, key_ty)` at every `contains`
    // call site, which *unifies* the needle-local's finalized
    // `BodyTypes::locals` entry with the container's own key/element type
    // right there. A local whose independent uses already established a
    // different concrete type doesn't surface as "provably mismatched" —
    // it unifies to `Ty::Conflicted` instead, which this pass (like every
    // sibling in the family) treats as unclassifiable ("Unknown never
    // disagrees"). A local with no independent type simply *becomes* the
    // container's key type, so it can never disagree with it either. Net
    // effect: this pass's local-needle reach is limited to what it can
    // prove empty (`Unknown`) — never a genuine positive. Flagged as scope
    // discovered beyond #582 rather than worked around here (would need a
    // needle-specific pre-observation type snapshot, a change to shared
    // inference substrate well outside this issue's fence).

    #[test]
    fn temp_array_needle_never_fires_self_unifies_or_conflicts() {
        let diags = check_all(
            "=== main ===\n~ temp xs = #[1, 2]\n~ x = contains(#{1: \"a\"}, xs)\n-> DONE\n",
        );
        assert!(
            diags.is_empty(),
            "a temp needle self-unifies/Conflicts against the container's key type, per the \
             `infer::body` `contains` arm — see this test's module-doc-adjacent comment: {diags:?}"
        );
    }

    #[test]
    fn temp_map_container_fires_when_needle_is_out_of_domain() {
        // The container (arg 0) is never touched by `observe` — only the
        // needle is — so a temp-valued *container* is unaffected by the
        // confound above and fires normally.
        let diags =
            check_all("=== main ===\n~ temp m = #{\"a\": 1}\n~ x = contains(m, 2.5)\n-> DONE\n");
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(diags[0].message.contains("float"), "{:?}", diags[0].message);
    }

    #[test]
    fn stitch_local_temp_needle_never_fires_self_unifies_or_conflicts() {
        let diags = check_all(
            "=== room ===\n= inside\n~ temp xs = #[1, 2]\n\
             ~ x = contains(#{1: \"a\"}, xs)\n-> DONE\n",
        );
        assert!(diags.is_empty(), "{diags:?}");
    }

    // ─── issue #2793: the ordinary (non-lambda) fn/knot annotated-param
    // half of #2786's `BodyTypes::locals` visibility fix — an UNWANTED
    // result flagged, not a new true positive ──────────────────────────

    /// #2793 audit: confirms — with an executable pin, not just prose — the
    /// gap this file's own module doc already records ("A local `temp`/param
    /// used as the *needle* … is structurally unreachable, discovered
    /// empirically … `self.observe(needle, key_ty)` … flagged as scope
    /// discovered beyond #582, not worked around here"). #2793 asks each of
    /// the six `BodyTypes::locals` consumers to be re-audited for #2786's
    /// annotated-param visibility specifically; for this file the answer is
    /// that #2786 cannot help at all here, for the *same*, already-known
    /// reason — an *ordinary* `fn`/knot param's own written annotation is
    /// no more visible to E152 at this position than an unannotated one
    /// ever was. Same shape as `coalesce.rs`'s own
    /// `annotated_fn_param_non_option_lhs_of_or_is_not_visible_to_e066`
    /// (independently rediscovered there before this pre-existing doc note
    /// was found here) — the mid-walk `observe` write beats the post-walk
    /// annotation overlay to `pass.locals`, and the overlay's `contains_key`
    /// #1912 firewall (correctly guarding the *re-bound-temp* case) then has
    /// no way to tell that entry apart from a legitimate one, so it never
    /// overlays `k`'s real `float` annotation on top. The lambda-param path
    /// is immune (see `lambda_param_own_annotation_still_flags_a_genuine_out_of_domain_needle`
    /// alongside this test) because `structs::pruned_locals_for_lambda`
    /// re-seeds a lambda's own annotated param straight from its
    /// `TypeExpr`, never reading back through this same mutated
    /// `pass.locals` map.
    ///
    /// Net effect, pinned here: `k: float` passed directly as
    /// `contains(#{1: "a"}, k)`'s needle records `k`'s finalized local type
    /// as `Int` — the container's own key type, in-domain — and raises no
    /// `E152` at all, even though `float` is provably outside a
    /// `Map<int, _>`'s key domain.
    ///
    /// Like `coalesce.rs`'s sibling finding, this is exactly the case
    /// `docs/typed-mode-spec.md` §2's **RULED (issue #1912)** paragraph
    /// already carves out — an intrinsic's sibling-argument `observe`
    /// (named there verbatim) is an evidence-producing position that
    /// deliberately never consults the annotation fallback, and this test
    /// does not reopen or contradict that ruling. What's new, not already
    /// covered by #1912: the walk-*assumed* container-key shape this arm
    /// back-propagates onto the needle gets exported as the param's own
    /// final signature type, which then contradicts the written annotation
    /// at a downstream CHECK consumer (`annotations::mismatches` / E063)
    /// for a non-exempt annotated type — see the `k: string` variant below.
    ///
    /// **Load-bearing caveat (review finding, #2819):** this fixture's
    /// "no diagnostic at all" is not just E152's own silence — it also
    /// depends on `k: float` being the *one* legal directional coercion
    /// `annotations::mismatches`'s `report_if_mismatched` exempts
    /// (`unify(Float, Int) == Float`, pinned independently by
    /// `annotations::tests::mismatches_is_silent_for_the_legal_int_to_float_coercion`).
    /// Swap the annotation to `k: string` and the identical mid-walk
    /// pre-emption instead surfaces as `E063` at the annotation site — see
    /// the sibling test immediately below. So "silent" here does not
    /// generalize to every disagreeing annotation at this needle position;
    /// it generalizes to E152 specifically staying silent, while whether
    /// *any* diagnostic fires at all still depends on which annotated type
    /// was written.
    #[test]
    fn annotated_fn_param_out_of_domain_needle_against_a_literal_container_is_not_visible_to_e152()
    {
        let src = "=== main(k: float) ===\n~ x = contains(#{1: \"a\"}, k)\n-> DONE\n";
        let (hir, index, resolutions, inference) = build_with_inference(src);
        let body = inference.bodies.values().next().expect("one inferable def");
        assert_eq!(
            body.locals.get("k"),
            Some(&Ty::Int),
            "documents the gap: the container's own key type, not `k`'s \
             real `float` annotation, is what the finalized local reflects: \
             {:?}",
            body.locals
        );
        let diags = check(&[(FileId(0), &hir)], &index, &inference, &resolutions);
        assert!(
            diags.is_empty(),
            "documents the gap: no E152 fires despite `k: float` disagreeing \
             with `contains`'s int-keyed domain here: {diags:?}"
        );
        // And this specific silence is not "no diagnostic anywhere" — it
        // is silent only because `float` is the one type `assignable`
        // exempts. `annotations::mismatches` still stays silent too, for
        // that exact reason (this fixture pins the load-bearing dependency
        // named above).
        let mismatch_diags =
            annotations::mismatches(&[(FileId(0), &hir)], &index, &inference, None);
        assert!(mismatch_diags.is_empty(), "{mismatch_diags:?}");
    }

    /// Same fixture, `k: string` instead of `k: float`: not exempted by
    /// `assignable`'s legal-coercion carve-out, so the same mid-walk
    /// pre-emption that silences E152 above surfaces here as `E063` at the
    /// annotation site — a different (and worse) diagnostic than a correct
    /// E152 at the `contains` call would be, not silence. Prevents the
    /// `float` fixture's silence from being read as "an annotated needle
    /// disagreement here is never visible to *any* check" — it depends on
    /// which type was written.
    #[test]
    fn annotated_fn_param_out_of_domain_needle_against_a_literal_container_surfaces_as_e063_for_a_non_exempt_annotation()
     {
        let src = "=== main(k: string) ===\n~ x = contains(#{1: \"a\"}, k)\n-> DONE\n";
        let (hir, index, resolutions, inference) = build_with_inference(src);
        let diags = check(&[(FileId(0), &hir)], &index, &inference, &resolutions);
        assert!(
            diags.is_empty(),
            "E152 itself still never sees this position: {diags:?}"
        );
        let mismatch_diags =
            annotations::mismatches(&[(FileId(0), &hir)], &index, &inference, None);
        assert_eq!(mismatch_diags.len(), 1, "{mismatch_diags:?}");
        assert_eq!(mismatch_diags[0].code, DiagnosticCode::E063);
        assert_eq!(
            mismatch_diags[0].message,
            "annotated type `string` disagrees with the type inferred from usage (`int`)",
            "{mismatch_diags:?}"
        );
    }

    #[test]
    fn unresolved_param_needle_stays_silent_when_unknown() {
        let diags = check_all("=== main(n) ===\n~ x = contains(#{1: \"a\"}, n)\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    // ── call-valued container/needle ─────────────────────────────────────

    #[test]
    fn call_valued_needle_fires_when_provably_out_of_domain() {
        let diags = check_all(
            "STRUCT Point = #{x: int}\n\
             === function shape() ===\n~ return Point#{x: 1}\n\
             === main ===\n~ x = contains(#{1: \"a\"}, shape())\n-> DONE\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert!(
            diags[0].message.contains("struct"),
            "{:?}",
            diags[0].message
        );
    }

    // ── shadowing, arity, and wrong-verb exclusions ──────────────────────

    #[test]
    fn author_defined_contains_shadowing_the_builtin_is_never_checked() {
        let diags = check_all(
            "=== function contains(a, b) ===\n~ return true\n\
             === main ===\n~ x = contains(#{1: \"a\"}, 3.5)\n-> DONE\n",
        );
        assert!(
            diags.is_empty(),
            "a resolved call to the author's own `contains` must never be flagged: {diags:?}"
        );
    }

    #[test]
    fn wrong_arity_contains_call_is_not_flagged_here() {
        let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"})\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    #[test]
    fn contains_value_is_never_checked_by_this_pass() {
        // A different verb (map *values*, no key-domain restriction) —
        // this pass only matches the literal name `contains`.
        let diags = check_all("=== main ===\n~ x = contains_value(#{1: \"a\"}, 3.5)\n-> DONE\n");
        assert!(diags.is_empty(), "{diags:?}");
    }

    // ── gradual mode: no static signal (module doc's inference-substrate
    // note) ───────────────────────────────────────────────────────────────

    #[test]
    fn gradual_mode_never_reaches_this_pass_at_all() {
        // `check` itself doesn't gate on `types` — `strict::check`'s own
        // wiring point does (this module doc's "why this needs the
        // inference substrate" section). This test pins the module-level
        // contract: called directly (as every test above does) it always
        // classifies from whatever `InferenceResult` it's handed, so the
        // *policy* gate lives one level up. Full pipeline reachability
        // (including the gradual-mode silence) is proven in
        // `crates/brink-compiler/tests/e0xx_diagnostics.rs`.
        let diags = check_all("=== main ===\n~ x = contains(#{1: \"a\"}, 3.5)\n-> DONE\n");
        assert_eq!(diags.len(), 1, "{diags:?}");
    }

    // ─── issue #2773: a lambda-own binding must not inherit an outer
    // same-named local's type ─────────────────────────────────────────

    /// Review finding on issue #2773: E152 was one of the two consumers the
    /// issue never named, so nothing in the corpus covered it — this file's
    /// `enter_lambda`/`exit_lambda` frame shipped with no fixture at all.
    ///
    /// `build`'s own temp `k` is `array`-typed (`[1, 2, 3]`), which
    /// `non_key_domain_kind_for_ty` maps to `"array"` — outside a
    /// `Map<int, _>`'s key domain. The lambda's own `k: int` param shadows
    /// it, and `int` is squarely *inside* that key domain. Pre-fix,
    /// `classify_expr_ty` read the outer `array` by bare name and raised a
    /// false-positive `E152`; post-fix the pruned frame seeds `k` from its
    /// own annotation and the fixture is clean.
    #[test]
    fn lambda_param_shadowing_outer_array_local_is_not_misclassified_as_out_of_domain() {
        let diags = check_all_native(
            "fn build() {\n  let k = [1, 2, 3];\n  let f = |k: int| {\n    contains(Map { 1: \"a\" }, k)\n  };\n}\n",
        );
        assert!(diags.is_empty(), "{diags:?}");
    }

    /// The positive control: pruning must not *silence* a genuine
    /// out-of-domain needle inside the lambda's own body. The lambda's own
    /// `k: Map<int, int>` param really is outside a `Map<int, _>`'s key
    /// domain, so `E152` must still fire — sourced from the lambda's own
    /// annotation, not from the outer `k`.
    #[test]
    fn lambda_param_own_annotation_still_flags_a_genuine_out_of_domain_needle() {
        let diags = check_all_native(
            "fn build() {\n  let k = [1, 2, 3];\n  let f = |k: Map<int, int>| {\n    contains(Map { 1: \"a\" }, k)\n  };\n}\n",
        );
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E152);
        assert!(diags[0].message.contains("map"), "{:?}", diags[0].message);
    }
}