Skip to main content

brink_analyzer/infer/
mod.rs

1//! `signature`/`infer_body`/`type_diagnostics` — the checker substrate
2//! (typed-mode-spec §2, TM-1). **Advisory-only**: this module produces
3//! inference *results* for later consumers (hover, TM-3 strict mode); it
4//! changes no compiler behavior and the LIR/codegen/runtime never read it.
5//!
6//! ## The firewall
7//!
8//! `infer_body(A)` (here, [`body::infer_def_body`] driven by
9//! [`infer_project`]) reads only `signature(B)` for every def `B` it calls —
10//! never `B`'s body. Two kinds of "signature" satisfy that rule:
11//!
12//! - A **global** (`VAR`/`CONST`) reads its declaration-derived
13//!   [`crate::Sig::value_type`] — the existing phase-0 stub, untouched by
14//!   this module (see `crate::signature`'s `signature_is_declaration_derived_only`
15//!   test, which this module must never make fail).
16//! - A **callable** (knot/stitch) reads its entry in `known_sigs`: either
17//!   another SCC's already-finalized [`InferredSig`], or — for a call
18//!   *within* the SCC currently being solved — that SCC's current fixpoint
19//!   estimate. Call-site-driven inference (letting a caller's argument
20//!   types flow backward into a callee's params) is never done; only a
21//!   callee's own already-computed signature flows forward into how the
22//!   caller's argument expressions are typed.
23//! - An **`EXTERNAL` binding** (issue #786) reads its own entry in
24//!   `known_sigs` too, seeded once up front by [`collect_external_sigs`]
25//!   from the registered `HostManifest` rather than solved by the fixpoint
26//!   (an external has no body to infer) — a call to it types its arguments
27//!   exactly like a callable's, through the same `known_sigs` lookup.
28//!
29//! ## SCC fixpoint
30//!
31//! [`graph::topo_order`] batches every inferable def into strongly-connected
32//! components, ordered so a component is solved only after every *other*
33//! component it calls is finalized. A component may contain more than one
34//! def (mutual recursion) — those solve together: seed every member with an
35//! `Unknown` working signature, re-run [`body::infer_def_body`] across the
36//! whole component, and repeat until no member's signature changes (or
37//! [`MAX_SCC_ITERATIONS`] is hit — guard against unbounded growth, house
38//! rule). This is the monomorphic Haskell-style binding-group solve spec §2
39//! asks for.
40//!
41//! ## Laziness (perf)
42//!
43//! [`infer_project`] is not wired into `analysis_query`, `lir_query`,
44//! `diagnostics_query`, or `story_data_query` — nothing in the existing
45//! compile/IDE path calls it. The brink-db salsa wrapper
46//! (`type_inference_query`) is therefore only ever computed when a consumer
47//! explicitly asks for `infer_body`/`type_diagnostics`, which today is
48//! nobody: this slice is pure substrate. See the PR's benchmark report for
49//! the before/after warm/cold numbers this predicts (no measurable delta on
50//! the existing paths).
51
52mod body;
53mod effects;
54mod graph;
55mod intrinsics;
56mod ty;
57
58pub(crate) use body::{is_string_numeric_concat, lambda_own_bindings};
59pub(crate) use intrinsics::{intrinsic_effects, intrinsic_returns_option};
60
61use std::collections::{BTreeMap, BTreeSet};
62
63use brink_format::{DefinitionId, DefinitionTag};
64use brink_ir::{
65    AssignOp, BaseType, Block, DocBlock, FileId, HirFile, HostManifest, Name, Param, ResolutionMap,
66    SymbolIndex, SymbolKind, TypeExpr, TypeRef,
67};
68use rowan::TextRange;
69
70pub use effects::{EffectAtoms, EffectRow, solve_scc_effects};
71pub use graph::{CallGraph, SccGraph, scc_graph};
72pub use ty::{
73    CoalesceError, FnRow, TowerTy, Ty, assignable, coalesce, erase_fn_rows, ref_assignable, unify,
74    unify_all,
75};
76
77use body::{BodyCtx, infer_def_body};
78use graph::topo_order;
79
80/// `TextRange` has no `Ord` impl (ranges have no single natural total
81/// order), so every `BTreeMap` keyed by a reference's source range in this
82/// module uses this `(start, end)` `u32` pair instead.
83fn range_key(range: TextRange) -> (u32, u32) {
84    (range.start().into(), range.end().into())
85}
86
87/// Caps the number of re-solve rounds for one SCC batch (guard against
88/// unbounded growth, house rule). Convergence is expected within a handful
89/// of rounds for this finite, monomorphic, no-overloading type universe —
90/// a genuinely pathological program that never stabilizes still terminates
91/// with whatever partial signature this cap leaves it at, which is legal
92/// (unresolved slots read as `Unknown`), not a hang.
93const MAX_SCC_ITERATIONS: usize = 8;
94
95/// A def's inferred signature: positional param types (declaration order)
96/// plus a return type. The generalized, per-def result of a body's fixpoint
97/// solve — what a *caller* reads (never the caller reading the callee's
98/// body directly; that's the firewall).
99#[derive(Debug, Clone, PartialEq, Eq, Default)]
100pub struct InferredSig {
101    pub params: Vec<Ty>,
102    pub return_ty: Ty,
103}
104
105/// The full inferred picture of one def's body: params, every local
106/// (params ∪ temps) by name, and the return type. A superset of
107/// [`InferredSig`] — `signatures` is the firewall-facing projection,
108/// `bodies` is what a hover/diagnostic consumer (TM-5) wants.
109#[derive(Debug, Clone, PartialEq, Eq, Default)]
110pub struct BodyTypes {
111    pub params: Vec<(String, Ty)>,
112    pub locals: BTreeMap<String, Ty>,
113    pub return_ty: Ty,
114    /// Issue #1028: whether the body contains at least one value-carrying
115    /// `return <expr>` anywhere — see [`body::BodyResult::has_value_return`]
116    /// (the field this one is copied from) for why `return_ty.is_unknown()`
117    /// alone can't distinguish "never returns a value" (should infer void)
118    /// from "returns a value inference couldn't pin down" (a real
119    /// Unknown-escape).
120    pub has_value_return: bool,
121    /// T1c (docs/t1c-spec.md §4): statically-checkable facts about calls
122    /// *through a value* (a callee resolving to a param/temp/VAR/CONST
123    /// rather than a callable def) observed in this body, in source-walk
124    /// order. Recorded unconditionally during inference (the walk is the
125    /// only place argument expressions have types); **reported only by
126    /// strict mode** (`strict::check` — gradual stays advisory, the runtime
127    /// fault is its backstop, spec §3/§4).
128    pub value_calls: Vec<ValueCallFact>,
129    /// Issue #1532: every `remove(a, i)` call site in this body whose first
130    /// argument is statically known to be `Ty::Array` — see
131    /// `body::BodyResult::array_remove_calls`'s doc for why this is
132    /// captured (the pre-#1484 array leg `remove` no longer serves).
133    /// Reported only by strict mode (`strict::check_array_remove_calls`,
134    /// `E149`), the same split as `value_calls`.
135    pub array_remove_calls: Vec<TextRange>,
136    /// Issue #1864: statically-checkable argument-type mismatches at
137    /// **direct** call sites (`h("hi")`, resolving straight to a known
138    /// knot/stitch via `known_sigs` — never a call through a value, which
139    /// [`ValueCallFact`]/[`ValueCallKind::ArgMismatch`] already covers).
140    /// Recorded unconditionally during inference, like `value_calls`;
141    /// reported only by strict mode — gradual mode keeps deferring to the
142    /// existing runtime type-mismatch fault as its backstop.
143    ///
144    /// Deliberately **excludes** an argument that is a bare `Path`
145    /// resolving to a `Param`/`Temp` in the caller's own body — the exact
146    /// set `InferPass::observe` unconditionally joins the callee's declared
147    /// param type into, right after this check runs (see
148    /// `body::InferPass::infer_call`'s doc for why). A genuine disagreement
149    /// there drives that local to `Ty::Conflicted` on its own, which
150    /// `strict::check_escapes` already reports as `E066` — recording a
151    /// second fact here for the identical disagreement would double-report
152    /// it. A `Path` argument resolving to anything else (a literal, a
153    /// nested call's return value, a global `VAR`/`CONST`, an index
154    /// expression, …) is unaffected by `observe` and stays fully checked.
155    pub direct_call_arg_mismatches: Vec<DirectCallArgMismatch>,
156    /// Issue #1877 (the remainder of #1864 left after PR #1875's direct-
157    /// call-argument half): statically-checkable type mismatches at a `~
158    /// temp name: T = expr` declaration initializer (against its own
159    /// ascription) or a plain `~ name = expr` assignment (against the
160    /// target's already-known declared type — a VAR/CONST's declaration-
161    /// derived type, or an annotated `~ temp`'s ascription). A `Param`
162    /// assignment target never reaches this fact at all: a param
163    /// annotation is a signature-firewall slot `annotations::mismatches`
164    /// (E063) already owns (compared against the body's *final* inferred
165    /// param type), so checking it again here would double-report the
166    /// identical disagreement. Recorded unconditionally during inference,
167    /// like `direct_call_arg_mismatches`; reported only by strict mode.
168    ///
169    /// A `Temp` assignment target is excluded from this fact whenever
170    /// `InferPass::observe`'s own join (which runs right after, on every
171    /// assignment) is *already* about to drive that local to
172    /// `Ty::Conflicted` on its own — that disagreement is independently
173    /// reported as `E066` by `strict::check_escapes`, so recording a second
174    /// fact here for it would double-report (mirrors
175    /// `DirectCallArgMismatch`'s own `arg_is_observed_local` exclusion, but
176    /// computed per-write rather than a blanket kind exclusion, since an
177    /// assignment to an as-yet-`Unknown` local never goes `Conflicted` and
178    /// would otherwise go unchecked entirely). That per-write guard is
179    /// order-sensitive — a *later* read of the same temp can independently
180    /// conflict it after a fact was already recorded — so
181    /// `body::infer_def_body` also drops, post-walk, any fact whose
182    /// target's *final* type is `Conflicted`. See
183    /// `body::InferPass::check_declared_assign_target`'s doc.
184    pub typed_assign_mismatches: Vec<TypedAssignMismatch>,
185    /// Issue #1900 (split from #1864/#1877): a dotted struct-field
186    /// assignment target (`~ p.x = expr`), with the root's declared type
187    /// resolved but the field chain past it left unresolved (no shape table
188    /// in this module — see [`FieldAssignMismatch`]'s own doc). Recorded
189    /// unconditionally during inference, like `typed_assign_mismatches`;
190    /// resolved and reported only by strict mode
191    /// (`structs::check_assignments`, `E063`).
192    pub field_assign_mismatches: Vec<FieldAssignMismatch>,
193    /// Issue #1994 (RULED 2026-08-01, closing #1932): a lambda's own
194    /// written param/return annotation disagreeing with its body-derived
195    /// type — see [`LambdaAnnotationMismatch`]'s own doc for why this is a
196    /// materially different severity posture from `typed_assign_mismatches`/
197    /// `field_assign_mismatches` above (an eager `Error`, not a gradual
198    /// `E063` advisory). Recorded unconditionally during inference, folded
199    /// in from every lambda anywhere in this body (including nested ones);
200    /// reported only by strict mode (`strict::check_lambda_annotation_
201    /// mismatches`, `E174`).
202    pub lambda_annotation_mismatches: Vec<LambdaAnnotationMismatch>,
203    /// Issue #1881: per-call-site *written*-argument types for every
204    /// UFCS-shaped (multi-segment, receiver-resolving) callee found in this
205    /// body — see [`UfcsCallArgs`]'s own doc for why this pass records raw
206    /// argument types here rather than checking them itself (the receiver
207    /// resolves to a value, so this pass's own callee resolution can never
208    /// see the desugared free function's declared param types the way
209    /// `brink_analyzer::ufcs`'s resolution pass can). Recorded
210    /// unconditionally, like `direct_call_arg_mismatches`; consumed by
211    /// `ufcs::UfcsVisitor`, reported only by strict mode
212    /// (`ufcs::check_strict`, `E063`).
213    pub ufcs_call_args: Vec<UfcsCallArgs>,
214    /// Issue #1770: see [`LambdaEscapeSlot`]. Recorded unconditionally,
215    /// folded in from every lambda anywhere in this body (including nested
216    /// ones) exactly like `lambda_annotation_mismatches`; reported only by
217    /// strict mode (`strict::check_def`, the same `E065`/`E066` codes a
218    /// top-level def's own params/temps already use).
219    pub lambda_escapes: Vec<LambdaEscapeSlot>,
220}
221
222/// One UFCS-desugared call site's (`recv.name(args)` → `name(recv, args)`)
223/// *written*-argument types (issue #1881) — the receiver itself is not
224/// included here (its type is already known directly to
225/// `ufcs::UfcsVisitor`, this fact's sole consumer, from receiver-type
226/// resolution). Recorded unconditionally at every multi-segment,
227/// value-resolving call this pass walks (see
228/// `body::InferPass::infer_call`'s own doc for why it cannot check anything
229/// against a UFCS receiver directly) — this is the raw per-argument type
230/// data `brink_analyzer::ufcs`'s own resolution pass needs to complete its
231/// own argument-type check against the desugared free function's
232/// already-known declared param types (`InferenceResult::signatures`, keyed
233/// by the *target*), without a second expression-type inference pass over
234/// the same body.
235///
236/// Issue #1909 gave `body::InferPass` a *narrow* target lookup of its own
237/// (`infer_ufcs_free_fn_result`, enough to type the call's **result**), but
238/// it deliberately declines the ambiguous, struct-receiver, projected-
239/// receiver and prelude cases this fact's consumer resolves properly — so
240/// the split stays: the result type is inference's, the argument-type
241/// *check* remains `ufcs`'s, fed by this fact.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct UfcsCallArgs {
244    /// The callee `Path`'s own source range (`recv.name`'s whole span) —
245    /// same convention as [`DirectCallArgMismatch::range`]; `ufcs::resolve`
246    /// keys its own verdict table on this identical range (the
247    /// `ResolvedRef::range` contract, issue #1561).
248    pub range: TextRange,
249    /// Each written argument's statically inferred type, in source order.
250    pub args: Vec<Ty>,
251}
252
253/// One statically-checkable type mismatch at a **declaration-initializer or
254/// assignment** site against an already-known declared type (issue #1877) —
255/// the `~ temp`/plain-assignment sibling of [`DirectCallArgMismatch`], which
256/// covers only direct-call arguments.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct TypedAssignMismatch {
259    /// The diagnostic site: the temp's own name range for a `~ temp`
260    /// initializer (matching `strict::collect_temps`'s escape-check anchor),
261    /// or the assignment target `Path`'s own range for a plain assignment
262    /// (matching [`DirectCallArgMismatch::range`]'s callee-range
263    /// convention).
264    pub range: TextRange,
265    /// The declared local/global's bare name.
266    pub target: String,
267    /// The target's already-known declared type.
268    pub expected: Ty,
269    /// The initializer/RHS expression's statically classified type.
270    pub found: Ty,
271}
272
273/// One incompatibility between a lambda's own **written annotation** (a
274/// param's `: T` or the lambda's `: R` return annotation) and its
275/// body-derived type (issue #1994, RULED 2026-08-01, closing #1932: "the
276/// written annotation takes priority... an incompatible body is an eager
277/// error at the lambda, not a deferred surprise at the call site").
278///
279/// Unlike [`TypedAssignMismatch`]/[`DirectCallArgMismatch`] (both `E063`,
280/// gradual/advisory — the body-derived type wins regardless, the
281/// annotation-vs-body comparison is only ever a warning), a mismatch
282/// recorded here is reported unconditionally as an `Error`-severity `E174`
283/// by `strict::check_lambda_annotation_mismatches` — the written annotation
284/// *replaces* the body-derived type at this slot (see
285/// `body::InferPass::infer_lambda`'s own doc for the precedence change),
286/// so a disagreement is never merely advisory.
287///
288/// Recorded only when a written annotation exists for this slot *and* the
289/// body-derived type is not itself unresolved (`Ty::is_unresolved`) — an
290/// unannotated slot has nothing to compare against and keeps #1910's
291/// unchanged body-derived-wins behavior, and an `Unknown`/`Conflicted`
292/// body-derived type never disagrees with anything (mirrors
293/// `annotations::report_if_mismatched`'s identical guard for the `fn`/`flow`
294/// case).
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub struct LambdaAnnotationMismatch {
297    /// The diagnostic site: the mismatched param's own `: T` annotation
298    /// range, or the lambda's own `: R` return-annotation range.
299    pub range: TextRange,
300    /// `Some(param name)` for a mismatched parameter annotation, `None` for
301    /// the lambda's own return annotation.
302    pub param_name: Option<String>,
303    /// The written annotation's resolved type — what now governs this
304    /// slot's type.
305    pub expected: Ty,
306    /// The body's own independent derivation, which disagreed.
307    pub found: Ty,
308}
309
310/// One lambda-body param or body-declared temp, ready for the same
311/// Unknown-escape (`E065`) / Conflicted-escape (`E066`) treatment
312/// `strict::check_def` already gives a top-level def's own `params`/
313/// `locals` (issue #1770: "lambda bodies are invisible to strict-mode
314/// escape checking... give lambda bodies a per-lambda frame").
315///
316/// A lambda literal still has no `DefinitionId`-keyed `BodyTypes` entry of
317/// its own to run `check_def` against — #1727 minted a lifted lambda a
318/// stable *identity*, not a `SymbolIndex` entry / `DefKey` (see that
319/// issue's ruling), and `infer_project`/`InferPass` run over HIR straight
320/// from `hir::lower`, strictly *before* `hir::stamp_container_ids` (which
321/// only runs as part of LIR lowering / the `normalized_stamped_query`
322/// salsa memo) — so even the identity #1727 does mint is not populated yet
323/// at the point this fact is recorded. Building a per-lambda strict-frame
324/// does not need it: each slot below is already a fully self-contained
325/// `emit_escape` input (final type, declaration range, annotation-exemption
326/// bit, and a ready-made slot label), so `strict::check_def` re-emits it
327/// with no per-lambda grouping or lookup required.
328///
329/// Recorded unconditionally by [`body::InferPass::infer_lambda`] for
330/// **every** lambda anywhere in the enclosing def's body, including one
331/// nested inside another lambda's own body — each nested lambda gets its
332/// own `infer_lambda` call and so contributes its own slots to this same
333/// flat, cumulative vector (mirrors [`LambdaAnnotationMismatch`]'s
334/// identical "folded in from every lambda anywhere in this body"
335/// precedent). Reported only by strict mode.
336///
337/// Deliberately **excludes** a lambda's own return-type slot: unlike a
338/// top-level `fn`'s return-type escape check, "does this lambda's body
339/// ever return a value" has no `E150`-style fall-through analysis defined
340/// for it, and #1994's `LambdaAnnotationMismatch` (`E174`) already owns a
341/// materially different, eager check for a lambda's return-type
342/// *annotation* disagreeing with its body — adding a second, gradual
343/// escape check for the identical slot would double-report the same fact
344/// under a different code. Out of scope for #1770; see that issue's own
345/// "Ask" (params + temps only, matching `BodyTypes::locals`'s own
346/// params-∪-temps membership, not `BodyTypes::return_ty`).
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct LambdaEscapeSlot {
349    /// The slot's own declaration range — a param's name range, or a
350    /// body-declared temp's/`if`-`as`-binding's/`for`-var's own name range.
351    /// The diagnostic anchor, same convention as `check_def`'s own
352    /// per-slot ranges.
353    pub range: TextRange,
354    /// The slot's final, escape-checked type.
355    pub ty: Ty,
356    /// Whether a resolvable annotation/ascription exempts an `Unknown`
357    /// classification (never a `Conflicted` one) — mirrors `check_def`'s
358    /// own `annotated` argument to `emit_escape`. Always `false` for a
359    /// param slot: `infer_lambda`'s own annotation-governs-when-present
360    /// overlay (#1994) already replaces an annotated param's `ty` with the
361    /// resolved annotation itself before this slot is built, so there is
362    /// nothing left for a separate exemption to do there — this field only
363    /// ever does real work for a body-declared temp, which carries no such
364    /// overlay.
365    ///
366    /// That holds only for a param whose name the lambda's own body never
367    /// re-binds. A name the body *does* re-bind (`|t: int| { let t = 1;
368    /// t = "oops"; t }`) never reaches a param slot at all — review finding
369    /// on #1770: the governance overlay's rebound-name branch reads `ty`
370    /// straight from `self.locals[name]`, i.e. the shadowing local's own
371    /// accumulated type, not the annotated param's, so `infer_lambda`
372    /// excludes that name from this loop entirely and reports it only as a
373    /// `` "lambda temp" `` slot instead (built from the same body-declared-
374    /// temps loop every ordinary temp goes through) — the escape belongs to
375    /// the fresh local, not the parameter of the same spelling.
376    pub annotated: bool,
377    /// The slot's own label, ready to hand straight to `emit_escape` — e.g.
378    /// `` "lambda parameter `x`" `` / `` "lambda temp `t`" `` — prefixed so
379    /// the reported message reads distinctly from the enclosing def's own
380    /// same-named slot (`check_def`'s `param_name` and `slot_label`
381    /// convention, one level in).
382    pub slot_label: String,
383}
384
385/// One statically-checkable type mismatch at a **dotted struct-field**
386/// assignment target (issue #1900, split from #1864/#1877 — PR #1899's own
387/// `check_declared_assign_target` explicitly excludes a multi-segment
388/// target, since a dotted target's declared type is its *root's* shape, not
389/// the field's).
390///
391/// Body inference resolves only the ROOT's declared type here (`ctx.globals`
392/// for a `VAR`/`CONST`, or an annotated Param/Temp's ascription — see
393/// `body::InferPass::check_declared_field_assign_target`'s doc) — it has no
394/// struct-shape table of its own (the firewall: a body never reads
395/// project-wide `STRUCT` declarations), so `path` is recorded unresolved.
396/// `structs::check_assignments` (strict-mode-only) walks `path` against
397/// `structs::declared_shapes`/`ShapeInfo` to resolve the specific field's
398/// declared type and reports `E063`.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct FieldAssignMismatch {
401    /// The root local/global's bare display name (`p` in `p.x = expr`).
402    pub root: String,
403    /// The root's resolved declared type — `Ty::Struct(name)` in the
404    /// classifiable case; anything else (`Unknown`, a scalar/collection) is
405    /// never recorded as a fact at all (see the recording site's own
406    /// "Unknown never disagrees" guard).
407    pub root_ty: Ty,
408    /// The field-access chain past the root, in source order (`p.x` →
409    /// `[x]`, `p.inner.x` → `[inner, x]`) — each segment's own `Name`
410    /// carries the range a per-field diagnostic should point at.
411    pub path: Vec<Name>,
412    /// The assignment's operator (`=`, `+=`, …). Carried alongside `found`
413    /// (issue #1900 review finding) so `structs::check_field_assign_mismatch`
414    /// — the only place the field's *declared* type is ever resolved — can
415    /// apply the same `+=` string-numeric display-concat carve-out
416    /// `Stmt::Assignment`'s own arm applies for a bare target: this body-
417    /// inference pass only knows the ROOT's type when the fact is recorded,
418    /// not the field's, so the carve-out can't be decided here.
419    pub op: AssignOp,
420    /// The RHS's statically inferred type.
421    pub found: Ty,
422}
423
424/// One statically-checkable argument-type mismatch recorded at any of
425/// three producer sites whose callee resolves straight to a known def via
426/// `known_sigs` — so its declared parameter types are already fully known
427/// at the site — unlike the T1c call-through-a-value case
428/// [`ValueCallFact`] exists for:
429///
430/// - a **direct call** (issue #1864) — `f(a, b)` where `f` names a known
431///   knot/stitch/function directly;
432/// - a `#fn(target, args…)` **creation site** (issue #2001) — not a call
433///   at all, but the by-ref *binding* site for a partial application;
434///   `target`'s remaining (unbound) params still go through the ordinary
435///   call-through-a-value check when the resulting `Ty::Fn` value is
436///   later invoked, but the *bound* prefix checked here is only ever
437///   checkable at creation.
438/// - a **divert with arguments** (issue #2127) — `-> knot(a, b)` — also not
439///   a call expression, but a `ref` position it binds is checked exactly
440///   like a direct call's `ref` argument (invariant, via `ref_assignable`).
441///   By-value positions at this site are **not** checked yet (#2127 scoped
442///   that out as its own design call, same posture #2001 took for
443///   `infer_fn_literal`'s by-value params).
444///
445/// `strict::check_direct_call_args`'s rendered message reads "argument N
446/// of call to `name`" for all three producers — accepted as-is for a `#fn`
447/// literal and a divert target too (both still name the target function's
448/// own parameter being populated), rather than adding a site-discriminant
449/// field to distinguish "creation of" / "divert to" from "call to";
450/// revisit if that reads as confusing in practice (#2001 review finding).
451#[derive(Debug, Clone, PartialEq, Eq)]
452pub struct DirectCallArgMismatch {
453    /// The diagnostic site's source range: the callee `Path`'s own range
454    /// for a direct call (same convention as [`ValueCallFact::range`]), the
455    /// `#fn` literal's `target` path range for a creation site, or the
456    /// divert's own target path range (issue #2127).
457    pub range: TextRange,
458    /// The callee's display name (`h` in `h("hi")`; dotted if the resolved
459    /// path had multiple segments, e.g. `Knot.stitch`).
460    pub callee: String,
461    /// The mismatched argument's 0-based position.
462    pub index: usize,
463    /// The callee's declared parameter type at `index`.
464    pub expected: Ty,
465    /// The argument expression's statically classified type.
466    pub found: Ty,
467}
468
469/// One statically-checkable fact about a call through a function value
470/// (T1c, docs/t1c-spec.md §4 — "under `types = strict`, calls through
471/// function values are statically checked").
472#[derive(Debug, Clone, PartialEq, Eq)]
473pub struct ValueCallFact {
474    /// The callee reference's source range (the diagnostic site).
475    pub range: TextRange,
476    /// The callee's display name (`f` in `f(5)`).
477    pub callee: String,
478    pub kind: ValueCallKind,
479}
480
481/// What a [`ValueCallFact`] observed. Strict mode maps these onto the
482/// existing TM-3 machinery — escape codes for unresolved callees, the
483/// typed-mismatch code for known-type disagreements — rather than minting
484/// parallel codes (docs/t1c-spec.md §8).
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub enum ValueCallKind {
487    /// The callee's type is `Unknown` in call position — a strict-mode
488    /// escape (`E065` class): the call can't be checked, so a strict author
489    /// must annotate or restructure.
490    UnknownCallee,
491    /// The callee's type is `Conflicted` (#627) in call position (`E066`
492    /// class).
493    ConflictedCallee,
494    /// The callee has a known concrete type that isn't `fn(T…): R` (and
495    /// isn't `divert` — calling through a divert-ref variable is a
496    /// pre-existing ink pattern this slice deliberately leaves unchecked).
497    NotCallable(Ty),
498    /// Known `fn(T…): R` callee, wrong argument count.
499    ArityMismatch { expected: usize, got: usize },
500    /// Known `fn(T…): R` callee; argument `index` (0-based) has a concrete
501    /// type that neither matches the row's param type nor coerces to it
502    /// (`int -> float` is the one legal directional coercion, spec §4).
503    ArgMismatch {
504        index: usize,
505        expected: Ty,
506        found: Ty,
507    },
508    /// `bind(f, args…)` (T1c-3, issue #733) supplied more args than remain
509    /// in the known `fn(T…): R` callee's param row — over-binding, distinct
510    /// from [`Self::ArityMismatch`] because `bind` has no fixed target arity
511    /// to match (binding fewer than the remaining params is legal; only
512    /// binding *more* is an error, mirroring the runtime's
513    /// `FunctionValueArity` fault and `#fn`'s own `E081` over-binding check).
514    OverBind { available: usize, got: usize },
515}
516
517/// The whole-project inference result (mirrors `AnalysisResult`'s shape:
518/// one pure function over already-computed inputs, callable directly or
519/// wrapped as a salsa query).
520#[derive(Debug, Clone, Default, PartialEq, Eq)]
521pub struct InferenceResult {
522    /// Every inferable (knot/stitch) def's finalized signature.
523    pub signatures: BTreeMap<DefinitionId, InferredSig>,
524    /// Every inferable def's full body type picture.
525    pub bodies: BTreeMap<DefinitionId, BodyTypes>,
526}
527
528impl From<crate::InferredType> for Ty {
529    fn from(t: crate::InferredType) -> Self {
530        match t {
531            crate::InferredType::Int => Ty::Int,
532            crate::InferredType::Float => Ty::Float,
533            crate::InferredType::Bool => Ty::Bool,
534            crate::InferredType::String => Ty::String,
535            crate::InferredType::Divert => Ty::Divert,
536            // Issue #628: the initializer-derived stub now carries the
537            // declaring LIST's name, so this round-trips to the same
538            // nominal `Ty::List` the annotation/body-inference paths use —
539            // no more conservative collapse to `Unknown`.
540            crate::InferredType::List(name) => Ty::List(name),
541        }
542    }
543}
544
545/// One inferable definition: its own id, declaring file, declared params,
546/// and body.
547///
548/// `pub` (FG-2.1, issue #638): `brink-db`'s `solve_scc_query` builds these
549/// itself from per-def `def_body_query` results (Ruling 2b's narrowed HIR
550/// projection) and passes them into [`solve_scc`] directly, instead of
551/// [`solve_scc`] rebuilding them via [`collect_defs`] over a whole-project
552/// (or even whole-file) HIR slice.
553#[derive(Debug, Clone, Copy)]
554pub struct Def<'a> {
555    pub id: DefinitionId,
556    pub file: FileId,
557    pub params: &'a [Param],
558    pub body: &'a Block,
559    /// The function-header return annotation (`): type ===`), when the def
560    /// is a knot that carries one (T1c — the boundary-annotation firewall
561    /// applied to the return slot: an `Unknown` inferred return overlays to
562    /// the annotated type, so `#fn` rows built from this signature are
563    /// concrete). `None` for stitches and unannotated knots.
564    pub return_annotation: Option<&'a TypeExpr>,
565    /// Which frontend produced [`Self::file`] — [`HirFile::native`] (issue
566    /// #1862), carried per def because that is the granularity every
567    /// consumer of this struct has: `brink-db`'s narrowed per-def HIR
568    /// projection never holds a whole [`HirFile`]. Reaches inference as
569    /// [`body::BodyCtx::native`], where the native bare-name fn-value rule
570    /// (issue #1876) keys off it.
571    pub native: bool,
572}
573
574/// Per-file resolution lookup: a `Path`'s range is only unique within its
575/// own file, so resolutions must never be merged across files.
576pub(crate) fn index_resolutions_by_file(
577    resolutions: &ResolutionMap,
578) -> BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>> {
579    let mut by_file: BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>> = BTreeMap::new();
580    for r in resolutions {
581        by_file
582            .entry(r.file)
583            .or_default()
584            .insert(range_key(r.range), r.target);
585    }
586    by_file
587}
588
589/// Every file's own declared module, read off any one symbol the index
590/// already has for it (module is uniform per file — every symbol
591/// `insert_file_symbols` inserts for a given file carries the identical
592/// `SymbolInfo::module`, since it is computed once per file from that
593/// file's own resolved `ModuleMap` entry, not re-derived per symbol). Feeds
594/// [`ProjectCtx::file_modules`] — see that field's own doc for why keying by
595/// [`FileId`] (rather than the def's own [`DefinitionId`], which the
596/// synthetic root-content def never has an index entry for) is required.
597///
598/// A file with zero indexed *global* symbols at all (pure top-level content
599/// with no named declaration) has no entry here and reads as `None` — the
600/// same conservative "absent data reads as empty" default every other
601/// module-blind path in this module already uses; nothing regresses
602/// relative to the pre-#2233 `None`-everywhere behavior for that case.
603///
604/// Skips every **local** (`info.scope.is_some()` — a param/temp) entirely:
605/// `insert_local` always stamps a local's own `SymbolInfo::module` `None`
606/// regardless of its file's real declared status ("locals are never
607/// module-qualified and always module-internal" — `manifest::insert_local`'s
608/// own doc), so folding one in would non-deterministically shadow a file's
609/// real module with `None` depending on `index.symbols`'s (`HashMap`-backed)
610/// iteration order — the exact per-run-flaky bug a first version of this
611/// function had.
612fn index_module_by_file(index: &SymbolIndex) -> BTreeMap<FileId, Option<String>> {
613    let mut by_file: BTreeMap<FileId, Option<String>> = BTreeMap::new();
614    for info in index.symbols.values() {
615        if info.scope.is_some() {
616            continue;
617        }
618        by_file
619            .entry(info.file)
620            .or_insert_with(|| info.module.clone());
621    }
622    by_file
623}
624
625/// Declaration-derived global (VAR/CONST) types — read via `signature()`,
626/// the firewall boundary for every non-callable reference in a body.
627///
628/// Reads [`Sig::value_ty`](crate::Sig::value_ty) — the declaration's type at
629/// full [`Ty`] fidelity. Before issue #1540 this read the narrow
630/// `Sig::value_type` (with a `Sig::fn_type` fallback), which had no
631/// representation for `Array`/`Map`/`Struct`/`Fn`/`Handle`, so a
632/// collection-typed global was invisible to every typed check keyed on this
633/// map — E149 and the TM-3/T1e family all missed `VAR arr = #[…]` entirely.
634/// One field now carries that whole domain, so nothing in it can fall out
635/// again. `range` is not part of that domain yet: it has no annotation
636/// grammar at all (`crate::annotations::resolve` has no arm for it), so a
637/// `VAR`/`CONST` can't be declared with one in the first place. (Stale
638/// pre-#1552 note, corrected for issue #2782: `Option<T>` **is** part of
639/// this domain — `annotations::resolve`'s `Generic` arm has handled it
640/// since #1552/PR #1804, so a `VAR`/`CONST` declared `Option<T>` reads
641/// through `Sig::value_ty` here exactly like `Array<T>`/`Map<K, V>` do.)
642///
643/// `pub(crate)` (issue #670) so `structs::check`'s non-literal struct-field
644/// classification can resolve a variable-valued initializer that names a
645/// global `VAR`/`CONST` against this exact same declaration-derived type,
646/// rather than re-deriving it.
647pub(crate) fn collect_globals(
648    files: &[(FileId, &HirFile)],
649    index: &SymbolIndex,
650    manifest: Option<&HostManifest>,
651) -> BTreeMap<DefinitionId, Ty> {
652    let mut globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
653    for (&id, info) in &index.symbols {
654        if matches!(info.kind, SymbolKind::Variable | SymbolKind::Constant)
655            && let Some(sig) = crate::signature::signature(id, index, files, manifest)
656            && let Some(ty) = sig.value_ty.clone()
657        {
658            globals.insert(id, ty);
659        }
660    }
661    globals
662}
663
664/// Declaration-derived `EXTERNAL` signatures (issue #786, docs/t1d-spec.md
665/// §3: "a binding declared to take `Handle<AudioInstance>` rejects a
666/// `Handle<Timer>` argument at compile time" under `types = strict`; issue
667/// #805 widens this to the manifest's full scalar-semantic-type vocabulary
668/// and to inline-doc-only bindings).
669///
670/// **Two consumers share this one resolution (issue #1004).** Both the
671/// call-site seeding — this map is folded into `solve_scc`/`infer_project`'s
672/// `known_sigs` so a call to a registered `EXTERNAL` checks its *arguments*
673/// against the declared param types — and [`crate::strict::check_external_escapes`]
674/// (the escape check over the *declarations themselves*, so a registered
675/// binding whose `ManifestParam.ty` fails to resolve is reported rather than
676/// silently treated as an untyped call) read the identical `(params, return)`
677/// signatures from here. The strict-escape reader lives on the shared
678/// [`crate::strict_diagnostics`] seam, so the analysis path
679/// (`analyze_with_options`) and the compile path (`brink-db`'s
680/// `whole_project_diagnostics_query`) get byte-identical external escapes
681/// from one helper — never a second, drift-prone re-resolution.
682///
683/// `EXTERNAL name(params)` has no ink-side type-annotation grammar (unlike a
684/// knot/stitch's `(x: T)`/`): T ===`), so a binding's *declared*
685/// parameter/return types can only come from two sources — exactly the two
686/// [`crate::external_check::analyze_externals`] already merges for its
687/// `SymbolMeta`/`E039`-`E042` enrichment: a matching entry in the registered
688/// [`HostManifest`]'s [`brink_ir::ManifestExternal`] list, and/or an inline
689/// `///` `@param`/`@returns` [`DocBlock`] parsed off the declaration itself.
690/// #805 reuses that same merge order here (inline wins by param name, else
691/// the registered entry wins by position) rather than re-deriving a second,
692/// narrower rule — an `EXTERNAL` documented purely via `///` tags, with no
693/// corresponding `ManifestExternal` entry at all, now seeds a signature too.
694///
695/// Every resolved [`TypeRef`] — handle-kinded or scalar — goes through
696/// [`type_ref_to_ty`], which looks the name up in the registered
697/// [`SemanticTypeDef`](brink_ir::SemanticTypeDef) table regardless of which
698/// source (manifest or inline doc) supplied the ref; a scalar semantic type
699/// (e.g. `switch_id`, `base: Int`) now types as its own `base` (`Ty::Int`)
700/// exactly like a `Handle<K>`-based one types as `Ty::Handle(K)` — the same
701/// `known_sigs`/`observe`/`unify` call-checking path applies to both, so a
702/// literal-typed argument that disagrees with a declared scalar semantic
703/// type folds to `Ty::Conflicted` and reports through the pre-existing
704/// `E066` classification, no new diagnostic code. This also covers
705/// return-position kind checking uniformly: `reg`/`inline`'s `returns` ref
706/// resolves through the identical `type_ref_to_ty` call as every param, so a
707/// binding's declared return kind (handle or scalar) becomes the call
708/// expression's own `Ty` wherever it's assigned or compared, through
709/// `infer_call`'s existing `sig.return_ty.clone()` — no separate return-only
710/// code path exists to fall out of sync with the param path.
711///
712/// No HIR read: entirely index + manifest + [`DocBlock`] derived (mirrors
713/// [`collect_globals`]'s shape) — `inline_docs` is itself HIR-free
714/// ([`DocBlock`] carries parsed doc content only, no source ranges), so this
715/// still has no per-file dependency edge to narrow.
716///
717/// An `EXTERNAL` with neither a registered manifest entry nor an inline doc
718/// contributes no signature at all — call sites stay exactly as unchecked as
719/// before this issue. A param/return whose resolved [`TypeRef`] names
720/// neither a base keyword nor a registered [`SemanticTypeDef`] types
721/// `Ty::Unknown` — the same conservative fallback every other unresolved
722/// slot in this module gets. `Ty::Unknown` params are inert at the
723/// call-checking site (`BodyCtx::observe` is a documented no-op against
724/// `Ty::Unknown`), so this never fabricates a false mismatch.
725pub fn collect_external_sigs(
726    index: &SymbolIndex,
727    manifest: Option<&HostManifest>,
728    inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
729) -> BTreeMap<DefinitionId, InferredSig> {
730    let mut sigs = BTreeMap::new();
731    let (types, registered) = crate::manifest_maps(manifest);
732    for (&id, info) in &index.symbols {
733        if info.kind != SymbolKind::External {
734            continue;
735        }
736        let inline = inline_docs.get(&(SymbolKind::External, info.name.clone()));
737        let reg = registered.get(info.name.as_str()).copied();
738        if inline.is_none() && reg.is_none() {
739            continue; // no declared signature at all — stays unchecked
740        }
741
742        // Param types: inline `@param` (by name) wins, else registered (by
743        // position) — the exact merge order `external_check::analyze_externals`
744        // uses for the same two sources.
745        let params: Vec<Ty> = info
746            .params
747            .iter()
748            .enumerate()
749            .map(|(i, p)| {
750                let tref: Option<&TypeRef> = inline
751                    .and_then(|d| d.params.iter().find(|(n, _)| n == &p.name).map(|(_, t)| t))
752                    .or_else(|| reg.and_then(|r| r.params.get(i).map(|mp| &mp.ty)));
753                tref.map_or(Ty::Unknown, |t| type_ref_to_ty(t, &types))
754            })
755            .collect();
756        let return_ty = inline
757            .and_then(|d| d.returns.as_ref())
758            .or_else(|| reg.map(|r| &r.returns))
759            .map_or(Ty::Unknown, |t| type_ref_to_ty(t, &types));
760        sigs.insert(id, InferredSig { params, return_ty });
761    }
762    sigs
763}
764
765/// Resolve a [`TypeRef`] (manifest- or inline-doc-sourced — both are the
766/// bare name form, resolution is identical either way) to a checker [`Ty`]
767/// (issue #805 — the full scalar-plus-handle slice of
768/// `external_check::resolve_type`'s domain, closed-domain constraints
769/// excluded: the checker substrate only needs a `Ty`, never a
770/// [`Constraint`](brink_ir::Constraint)). A base scalar keyword
771/// (`string`/`int`/`float`/`bool`) resolves directly; a name registered in
772/// `types` resolves through its own [`SemanticTypeDef::base`] — `Ty::String`/
773/// `Ty::Int`/`Ty::Float`/`Ty::Bool` for a scalar specialization (e.g.
774/// `switch_id`, `base: Int`), `Ty::Handle(name)` for a `base: Handle` kind
775/// definition (T1d-2, docs/t1d-spec.md §3 — the def's own `name` *is* the
776/// declared handle-kind name `Handle<K>` annotations resolve `K` against).
777/// `void` (either the bare keyword or a registered `base: Void` def) has no
778/// `Ty` (return-only, same as an annotation's `void`); an unresolved name —
779/// no manifest at all, or a name neither a base keyword nor a registered
780/// semantic type — types `Ty::Unknown` (unresolved — never a hard failure).
781///
782/// Classification goes through [`crate::type_resolution::classify`] — the
783/// same function `external_check::resolve_type` (hover/pickers) uses — so an
784/// **unregistered** name (`TypeShape::Unregistered`) types `Ty::Unknown`
785/// here exactly as consistently as it renders `base: None` there (#1027;
786/// closes the #1004 divergence where hover showed a confident `id: var_id`
787/// for a name inference correctly called `Unknown`).
788fn type_ref_to_ty(t: &TypeRef, types: &BTreeMap<String, brink_ir::SemanticTypeDef>) -> Ty {
789    use crate::type_resolution::{TypeShape, classify};
790
791    match classify(t, types) {
792        // Unspecified/unregistered are conservatively `Unknown` (#1027 —
793        // `TypeShape::Unregistered` is exactly the class
794        // `external_check::resolve_type` renders `base: None` for). The
795        // bare `void`/`handle` keyword literals join them here too: `void`
796        // is return-only (no represented `Ty`), and a bare `handle` (no
797        // kind name) isn't a `Ty::Handle` either — that needs the kind name
798        // itself, which only ever arrives as a *registered* name (i.e.
799        // `TypeShape::Registered` below), never as the literal keyword
800        // `handle`.
801        TypeShape::Unspecified
802        | TypeShape::Unregistered
803        | TypeShape::Base(BaseType::Void | BaseType::Handle) => Ty::Unknown,
804        TypeShape::Base(BaseType::String) => Ty::String,
805        TypeShape::Base(BaseType::Int) => Ty::Int,
806        TypeShape::Base(BaseType::Float) => Ty::Float,
807        TypeShape::Base(BaseType::Bool) => Ty::Bool,
808        TypeShape::Registered(def) => match def.base {
809            BaseType::String => Ty::String,
810            BaseType::Int => Ty::Int,
811            BaseType::Float => Ty::Float,
812            BaseType::Bool => Ty::Bool,
813            BaseType::Void => Ty::Unknown,
814            BaseType::Handle => Ty::Handle(t.0.trim().to_string()),
815        },
816    }
817}
818
819/// The synthetic `DefinitionId` `hir.root_content`'s own inference results
820/// are keyed under (issue #1903). Root content has no parameters, no
821/// return type, and no `DefinitionId` in the symbol table, so this mixes a
822/// tag bit into the `FileId` to avoid colliding with a real definition id;
823/// the id is never looked up in the symbol table, only used to key
824/// `inference.bodies` so a later check can read the results back out.
825///
826/// This is the scheme's origin — [`collect_defs`] below synthesizes it to
827/// drive inference over `root_content` in the first place. Issue #2772
828/// review finding: every other site that needs to key into
829/// `inference.bodies` for root content's own def
830/// (`strict::check_direct_call_args`, `strict::body_def_ids`,
831/// `option_conditions::check`) must call this rather than re-deriving the
832/// formula inline, so a future move to module-qualified ids only has to
833/// change once.
834pub(crate) fn root_content_def_id(file: FileId) -> DefinitionId {
835    DefinitionId::new(DefinitionTag::LocalVar, u64::from(file.0))
836}
837
838/// Every inferable (knot/stitch) def in the project, resolved back to its
839/// own `DefinitionId` via `(file, kind, qualified name)` — HIR `Knot`/
840/// `Stitch` nodes carry only a bare `Name`, not their own id.
841///
842/// A *floating* stitch (`= stitch`, declared before any `== knot ==`
843/// header) lowers into `hir.knots` as a `Knot` node (`NodeClass::Stitch` provenance)
844/// but was declared `SymbolKind::Stitch` with a bare name by
845/// `lower_top_level_stitch` — never `SymbolKind::Knot`, and never qualified
846/// with a knot prefix (there is no enclosing knot). So the symbol-kind used
847/// for the `def_of` lookup must track `knot.ptr`, not assume every
848/// `hir.knots` entry is a real `SymbolKind::Knot` (#626).
849pub(crate) fn collect_defs<'a>(
850    files: &[(FileId, &'a HirFile)],
851    index: &SymbolIndex,
852) -> Vec<Def<'a>> {
853    let mut def_of: BTreeMap<(FileId, SymbolKind, String), DefinitionId> = BTreeMap::new();
854    for (&id, info) in &index.symbols {
855        def_of.insert((info.file, info.kind, info.name.clone()), id);
856    }
857
858    let mut defs: Vec<Def<'a>> = Vec::new();
859    for &(file_id, hir) in files {
860        // Issue #1903: walk `root_content` statements through inference,
861        // creating a synthetic def so `check_declared_assign_target` and
862        // `check_declared_temp_init` (called from `infer_def_body`) reach them.
863        // Root content has no parameters, no return type, and no DefinitionId
864        // in the symbol table. A synthetic ID is derived from the FileId
865        // (mixing in a tag bit to avoid collision with real definition IDs);
866        // the ID is never looked up in the symbol table, only used to key
867        // `inference.bodies` so that strict checks later read the results.
868        if !hir.root_content.stmts.is_empty() {
869            let synthetic_id = root_content_def_id(file_id);
870            defs.push(Def {
871                id: synthetic_id,
872                file: file_id,
873                params: &[],
874                body: &hir.root_content,
875                return_annotation: None,
876                native: hir.native,
877            });
878        }
879
880        for knot in &hir.knots {
881            let knot_symbol_kind = knot.symbol_kind();
882            if let Some(&id) = def_of.get(&(file_id, knot_symbol_kind, knot.name.text.clone())) {
883                defs.push(Def {
884                    id,
885                    file: file_id,
886                    params: &knot.params,
887                    body: &knot.body,
888                    return_annotation: knot.return_type.as_ref(),
889                    native: hir.native,
890                });
891            }
892            for stitch in &knot.stitches {
893                let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
894                if let Some(&id) = def_of.get(&(file_id, SymbolKind::Stitch, qualified)) {
895                    defs.push(Def {
896                        id,
897                        file: file_id,
898                        params: &stitch.params,
899                        body: &stitch.body,
900                        // #1509 widened `Stitch` with the same `return_type`
901                        // grammar position `Knot` carries.
902                        return_annotation: stitch.return_type.as_ref(),
903                        native: hir.native,
904                    });
905                }
906            }
907        }
908    }
909    defs.sort_by_key(|d| d.id);
910    defs
911}
912
913/// Shared read-only context every pass over `defs` needs.
914struct ProjectCtx<'a> {
915    index: &'a SymbolIndex,
916    globals: &'a BTreeMap<DefinitionId, Ty>,
917    by_file: &'a BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>>,
918    inferable: &'a BTreeSet<DefinitionId>,
919    /// Declared `LIST`/`STRUCT` names, computed once per context — needed
920    /// by the T1c annotation-firewall overlay (`annotations::resolve` of
921    /// param/return/temp annotations inside [`body::infer_def_body`]).
922    list_names: BTreeSet<String>,
923    struct_names: BTreeSet<String>,
924    /// Declared handle-kind names from the registered `HostManifest`
925    /// (T1d-2b, issue #774, docs/t1d-spec.md §3) — computed once per
926    /// context, same shape as `list_names`/`struct_names`, so `Handle<K>`
927    /// param/return/temp annotations resolve during body inference too, not
928    /// just at the `signature()`/annotation-firewall seam.
929    handle_names: BTreeSet<String>,
930    /// Every file's own declared module (`None` for an undeclared
931    /// stem-module or a file with no indexed symbols at all), keyed by
932    /// [`FileId`] rather than [`DefinitionId`] — module is a per-*file*
933    /// fact, not a per-symbol one: every symbol `insert_file_symbols`
934    /// (`brink-analyzer::manifest`) inserts for one file carries the
935    /// identical [`brink_ir::SymbolInfo::module`], derived once from that
936    /// file's own resolved [`crate::ModuleMap`] entry.
937    ///
938    /// Issue #2233 review finding: `body_ctx` used to read this straight off
939    /// `index.symbols.get(&def.id).module`, keyed on the def's own id — which
940    /// always misses for the synthetic root-content def [`collect_defs`]
941    /// mints for `hir.root_content` (issue #1903; that id is "never looked
942    /// up in the symbol table" by its own doc), silently leaving
943    /// `referrer_module: None` for *every* file with non-empty top-level ink
944    /// content, including one declared inside `std…` — exactly the #2233
945    /// disagreement this ctx exists to close. Keying by [`FileId`] instead
946    /// covers both the real and the synthetic def uniformly, since both
947    /// carry [`Def::file`].
948    file_modules: BTreeMap<FileId, Option<String>>,
949}
950
951impl<'a> ProjectCtx<'a> {
952    fn new(
953        index: &'a SymbolIndex,
954        globals: &'a BTreeMap<DefinitionId, Ty>,
955        by_file: &'a BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>>,
956        inferable: &'a BTreeSet<DefinitionId>,
957        manifest: Option<&HostManifest>,
958    ) -> Self {
959        Self {
960            index,
961            globals,
962            by_file,
963            inferable,
964            list_names: crate::annotations::declared_list_names(index),
965            struct_names: crate::annotations::declared_struct_names(index),
966            handle_names: crate::annotations::declared_handle_kinds(manifest),
967            file_modules: index_module_by_file(index),
968        }
969    }
970
971    fn body_ctx(
972        &'a self,
973        def: &Def<'_>,
974        known_sigs: &'a BTreeMap<DefinitionId, InferredSig>,
975    ) -> BodyCtx<'a> {
976        static EMPTY: BTreeMap<(u32, u32), DefinitionId> = BTreeMap::new();
977        BodyCtx {
978            resolution_by_range: self.by_file.get(&def.file).unwrap_or(&EMPTY),
979            index: self.index,
980            globals: self.globals,
981            known_sigs,
982            inferable: self.inferable,
983            list_names: &self.list_names,
984            struct_names: &self.struct_names,
985            handle_names: &self.handle_names,
986            // Per *def*, not per project: `ProjectCtx` is shared across
987            // every batch, and a project can mix `.ink` and `.brink` files
988            // (INCLUDE/IMPORT across surfaces), so the frontend flag must
989            // follow the body being walked — issue #1876.
990            native: def.native,
991            // Issue #2233 (review finding: keyed by file, not by the def's
992            // own id — see `ProjectCtx::file_modules`'s doc for why the
993            // synthetic root-content def needs this). `None` for a file with
994            // no `file_modules` entry (no indexed symbols at all) or whose
995            // own module is the legacy undeclared-stem-module `None`.
996            referrer_module: self
997                .file_modules
998                .get(&def.file)
999                .and_then(|module| module.as_deref()),
1000        }
1001    }
1002}
1003
1004/// Pass 1: call-graph edges only. `known_sigs` is empty here — every call
1005/// resolves to `Unknown` and the resulting types are discarded — this pass
1006/// exists solely to discover which defs call which, which the SCC batching
1007/// (pass 2) needs before any real solving can start.
1008fn build_call_graph(defs: &[Def<'_>], ctx: &ProjectCtx<'_>) -> CallGraph {
1009    let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
1010    let mut graph = CallGraph::new();
1011    for d in defs {
1012        graph.add_node(d.id);
1013        let body_ctx = ctx.body_ctx(d, &no_sigs);
1014        let result = infer_def_body(d, &body_ctx);
1015        for callee in result.calls {
1016            graph.add_edge(d.id, callee);
1017        }
1018    }
1019    graph
1020}
1021
1022/// Solve one SCC batch's fixpoint in place (the per-batch body of pass 2):
1023/// extends `known_sigs` with `batch`'s own members' finalized signatures —
1024/// seeded `Unknown`, re-run until stable or [`MAX_SCC_ITERATIONS`] — and
1025/// returns `batch`'s finalized [`BodyTypes`]. `known_sigs` must already carry
1026/// the finalized signature of every def *outside* `batch` that a member of
1027/// `batch` calls (every earlier batch's signature, for [`solve_batches`]'s
1028/// whole-project loop; every condensation-predecessor SCC's signature, for
1029/// the public [`solve_scc`] — FG-2, issue #631).
1030///
1031/// Shared by [`solve_batches`] (`ctx`/`by_id` built once, looped over every
1032/// batch — unchanged cost from before this function was extracted) and
1033/// [`solve_scc`] (`ctx`/`by_id` rebuilt per call, one batch at a time — the
1034/// new per-SCC query boundary).
1035fn solve_one_batch(
1036    batch: &BTreeSet<DefinitionId>,
1037    by_id: &BTreeMap<DefinitionId, &Def<'_>>,
1038    ctx: &ProjectCtx<'_>,
1039    known_sigs: &mut BTreeMap<DefinitionId, InferredSig>,
1040) -> BTreeMap<DefinitionId, BodyTypes> {
1041    for &id in batch {
1042        known_sigs.entry(id).or_insert_with(|| {
1043            let param_count = by_id.get(&id).map_or(0, |d| d.params.len());
1044            InferredSig {
1045                params: vec![Ty::Unknown; param_count],
1046                return_ty: Ty::Unknown,
1047            }
1048        });
1049    }
1050
1051    let mut last_round: BTreeMap<DefinitionId, body::BodyResult> = BTreeMap::new();
1052    for _round in 0..MAX_SCC_ITERATIONS {
1053        let mut round: BTreeMap<DefinitionId, body::BodyResult> = BTreeMap::new();
1054        let mut changed = false;
1055        for &id in batch {
1056            let Some(&d) = by_id.get(&id) else { continue };
1057            let body_ctx = ctx.body_ctx(d, known_sigs);
1058            let result = infer_def_body(d, &body_ctx);
1059            let new_sig = InferredSig {
1060                params: result.params.iter().map(|(_, t)| t.clone()).collect(),
1061                return_ty: result.return_ty.clone(),
1062            };
1063            if known_sigs.get(&id) != Some(&new_sig) {
1064                changed = true;
1065            }
1066            known_sigs.insert(id, new_sig);
1067            round.insert(id, result);
1068        }
1069        last_round = round;
1070        if !changed {
1071            break;
1072        }
1073    }
1074
1075    last_round
1076        .into_iter()
1077        .map(|(id, result)| {
1078            (
1079                id,
1080                BodyTypes {
1081                    params: result.params,
1082                    locals: result.locals,
1083                    return_ty: result.return_ty,
1084                    has_value_return: result.has_value_return,
1085                    value_calls: result.value_calls,
1086                    array_remove_calls: result.array_remove_calls,
1087                    direct_call_arg_mismatches: result.direct_call_arg_mismatches,
1088                    typed_assign_mismatches: result.typed_assign_mismatches,
1089                    field_assign_mismatches: result.field_assign_mismatches,
1090                    lambda_annotation_mismatches: result.lambda_annotation_mismatches,
1091                    ufcs_call_args: result.ufcs_call_args,
1092                    lambda_escapes: result.lambda_escapes,
1093                },
1094            )
1095        })
1096        .collect()
1097}
1098
1099/// Pass 2: solve every SCC batch in dependency order, mutually-recursive
1100/// batches by fixpoint (spec §2's SCC rule — see the module doc).
1101///
1102/// `external_sigs` (issue #786): every `EXTERNAL`'s declaration-derived
1103/// signature ([`collect_external_sigs`]), seeded into `known_sigs` before any
1104/// batch solves — a call to an external now resolves through the exact same
1105/// `known_sigs` lookup + [`body::BodyCtx::observe`] unify path an ordinary
1106/// knot/stitch call already uses, so a `Handle<K>`-mismatched argument folds
1107/// its local to `Ty::Conflicted` and reports through the pre-existing `E066`
1108/// classification, no parallel checking surface. Externals are never SCC
1109/// members (never in any `batch`), so this seed is never touched again by
1110/// the per-batch fixpoint loop below.
1111fn solve_batches(
1112    batches: &[BTreeSet<DefinitionId>],
1113    by_id: &BTreeMap<DefinitionId, &Def<'_>>,
1114    ctx: &ProjectCtx<'_>,
1115    external_sigs: &BTreeMap<DefinitionId, InferredSig>,
1116) -> (
1117    BTreeMap<DefinitionId, InferredSig>,
1118    BTreeMap<DefinitionId, BodyTypes>,
1119) {
1120    let mut known_sigs: BTreeMap<DefinitionId, InferredSig> = external_sigs.clone();
1121    let mut bodies: BTreeMap<DefinitionId, BodyTypes> = BTreeMap::new();
1122
1123    for batch in batches {
1124        let batch_bodies = solve_one_batch(batch, by_id, ctx, &mut known_sigs);
1125        bodies.extend(batch_bodies);
1126    }
1127
1128    (known_sigs, bodies)
1129}
1130
1131/// Infer types for every knot/stitch body across the whole project.
1132///
1133/// Pure function of already-computed inputs (`index`/`resolutions`, the
1134/// same shape `finish_analysis`/`signature` take) — safe to call directly in
1135/// tests, and the exact function `type_inference_query` wraps for salsa
1136/// memoization. `manifest` (T1d-2b, issue #774): the registered host
1137/// manifest, threaded through to `signature()`/annotation resolution so
1138/// `Handle<K>` param/return/temp annotations resolve to `Ty::Handle(K)`
1139/// during body inference — `None` degrades to an empty handle-kind set,
1140/// same posture as every other manifest-driven check. Also threaded to
1141/// [`collect_external_sigs`] (issue #786) so a call to a manifest-registered
1142/// `EXTERNAL` checks its arguments against the binding's declared param
1143/// types the same way a knot/stitch call already does. `inline_docs` (issue
1144/// #805): the project-wide merged `///` doc-comment map
1145/// ([`crate::project_inline_docs`]'s output), the second of
1146/// [`collect_external_sigs`]'s two signature sources — an empty map degrades
1147/// to manifest-only seeding, byte-identical to pre-#805 behavior.
1148#[must_use]
1149pub fn infer_project(
1150    files: &[(FileId, &HirFile)],
1151    index: &SymbolIndex,
1152    resolutions: &ResolutionMap,
1153    manifest: Option<&HostManifest>,
1154    inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1155) -> InferenceResult {
1156    let by_file = index_resolutions_by_file(resolutions);
1157    let globals = collect_globals(files, index, manifest);
1158    let defs = collect_defs(files, index);
1159    let inferable: BTreeSet<DefinitionId> = defs.iter().map(|d| d.id).collect();
1160    let by_id: BTreeMap<DefinitionId, &Def<'_>> = defs.iter().map(|d| (d.id, d)).collect();
1161
1162    let ctx = ProjectCtx::new(index, &globals, &by_file, &inferable, manifest);
1163    let external_sigs = collect_external_sigs(index, manifest, inline_docs);
1164
1165    let graph = build_call_graph(&defs, &ctx);
1166    let batches = topo_order(&graph);
1167    let (signatures, bodies) = solve_batches(&batches, &by_id, &ctx, &external_sigs);
1168
1169    InferenceResult { signatures, bodies }
1170}
1171
1172// ─── Per-def/per-SCC query boundary (FG-2, issue #631) ────────────────
1173//
1174// `docs/fine-grained-salsa-proposal.md` §2 decomposes `infer_project` into
1175// `call_edges(def) -> scc_membership() -> solve_scc(SccId) ->
1176// inferred_signature(def)`. This module keeps every algorithm exactly as
1177// `infer_project` already used it (SCC/condensation in `graph.rs`, the
1178// single-batch fixpoint above); `brink-db` owns the query *keys*, *edges*,
1179// and `SccId` interning (a plain `DefinitionId` — the component's minimum
1180// member, already `graph.rs`'s own sort key) that turn these pure functions
1181// into salsa-memoized, per-def/per-SCC-cacheable ones.
1182
1183/// Every inferable (knot/stitch) definition's id in the project (FG-2, issue
1184/// #631). A cheap structural scan — needs the whole project's HIR to
1185/// enumerate every def's body. Superseded, for `brink-db`'s per-def/per-SCC
1186/// query wiring, by [`inferable_defs_from_index`] (FG-2.1, issue #638,
1187/// Ruling 2b — the same id set, sourced from the index alone, no HIR read);
1188/// kept for direct pure-function callers (e.g. [`infer_project`]) and as the
1189/// equivalence anchor `inferable_defs_from_index_matches_hir_derived_set`
1190/// pins.
1191#[must_use]
1192pub fn inferable_defs(files: &[(FileId, &HirFile)], index: &SymbolIndex) -> BTreeSet<DefinitionId> {
1193    collect_defs(files, index).iter().map(|d| d.id).collect()
1194}
1195
1196/// The same inferable (knot/stitch) def id set as [`inferable_defs`], read
1197/// directly off the index's `SymbolKind` — no HIR (FG-2.1, issue #638,
1198/// Ruling 2b: "`inferable` comes from an index-sourced `inferable_defs_query`
1199/// (dep = `inference_index_query`, not HIR)"). A knot/stitch symbol is
1200/// always indexed at exactly the same moment its `hir.knots` entry is
1201/// lowered (`lower_single_knot`/`lower_top_level`), so filtering
1202/// `index.symbols` by kind here is output-identical to walking every file's
1203/// HIR the way [`inferable_defs`] does — pinned by
1204/// `inferable_defs_from_index_matches_hir_derived_set`.
1205#[must_use]
1206pub fn inferable_defs_from_index(index: &SymbolIndex) -> BTreeSet<DefinitionId> {
1207    index
1208        .symbols
1209        .iter()
1210        .filter(|(_, info)| matches!(info.kind, SymbolKind::Knot | SymbolKind::Stitch))
1211        .map(|(&id, _)| id)
1212        .collect()
1213}
1214
1215/// Find one inferable def's own params + body from a declaring-file-scoped
1216/// HIR slice alone (FG-2.1, issue #638, Ruling 2b — backs `brink-db`'s
1217/// per-def `def_body_query(def)` projection, the `inference_index_query`
1218/// precedent applied to bodies). A thin filter over the same
1219/// [`collect_defs`] walk [`call_edges`]/[`solve_scc`] already used
1220/// project-wide, scoped here to exactly `def`'s declaring file so the salsa
1221/// wrapper records a read-edge on only that file's `lowered_query` — not
1222/// every project file's. Returns owned data (`Vec<Param>`/`Block` both
1223/// `Clone`) since the salsa caller stores the result in a long-lived memo,
1224/// past the borrow of any one `lowered_query` call.
1225#[must_use]
1226pub fn def_body(
1227    def: DefinitionId,
1228    declaring_file_hir: &[(FileId, &HirFile)],
1229    index: &SymbolIndex,
1230) -> Option<(Vec<Param>, Option<TypeExpr>, Block)> {
1231    collect_defs(declaring_file_hir, index)
1232        .into_iter()
1233        .find(|d| d.id == def)
1234        .map(|d| {
1235            (
1236                d.params.to_vec(),
1237                d.return_annotation.cloned(),
1238                d.body.clone(),
1239            )
1240        })
1241}
1242
1243/// Pass 1, exposed per one definition (FG-2, issue #631 — `call_edges(def)`).
1244/// Computes exactly what [`build_call_graph`]'s loop body computes for one
1245/// def: infer this def's body with `known_sigs` empty (every call resolves
1246/// `Unknown`; only the *set* of resolved call targets is kept, matching the
1247/// design doc's explicit "keep reusing `infer_def_body` and discard types,
1248/// as today" allowance for this query). Returns an empty set for an
1249/// unknown/non-inferable def id — same "absent data reads as empty, never
1250/// panics" contract as the rest of this module.
1251///
1252/// **Narrowed inputs (FG-2.1, issue #638, Ruling 2a).** `declaring_file_hir`
1253/// need only cover `def`'s own declaring file (pass 1 never needs any other
1254/// file's HIR to find one def's own body); `inferable` is caller-supplied
1255/// (index-sourced — see [`inferable_defs_from_index`]) rather than
1256/// recomputed via [`collect_defs`] over the narrowed slice, because a
1257/// resolved call target can land in a *different* file than `def`'s own.
1258/// `collect_globals` is dropped entirely — pass 1 discards every computed
1259/// type (spec §5), so a permanently-empty globals map is behavior-identical
1260/// and strictly cheaper.
1261///
1262/// `manifest` (T1d-2b, issue #774): threaded through to `ProjectCtx` for the
1263/// same reason every other per-def FG-2 seam now carries it — `call_edges`
1264/// discards every computed type (only the *set* of call targets survives),
1265/// so which handle kinds are registered can never change this function's
1266/// output; the parameter exists so `brink-db`'s `call_edges_query` doesn't
1267/// need a second, differently-shaped code path just to reach the manifest
1268/// `referenced_globals`/`solve_scc` also need.
1269#[must_use]
1270pub fn call_edges(
1271    def: DefinitionId,
1272    declaring_file_hir: &[(FileId, &HirFile)],
1273    index: &SymbolIndex,
1274    resolutions: &ResolutionMap,
1275    inferable: &BTreeSet<DefinitionId>,
1276    manifest: Option<&HostManifest>,
1277) -> BTreeSet<DefinitionId> {
1278    let by_file = index_resolutions_by_file(resolutions);
1279    let defs = collect_defs(declaring_file_hir, index);
1280    let Some(d) = defs.iter().find(|d| d.id == def) else {
1281        return BTreeSet::new();
1282    };
1283    let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
1284    let ctx = ProjectCtx::new(index, &empty_globals, &by_file, inferable, manifest);
1285    let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
1286    let body_ctx = ctx.body_ctx(d, &no_sigs);
1287    infer_def_body(d, &body_ctx).calls
1288}
1289
1290/// Pass 1b, exposed per one definition (FG-2.1, issue #638, Ruling 1 —
1291/// `referenced_globals(def)`, the same per-def body-facts family as
1292/// [`call_edges`]). The VAR/CONST global ids `def`'s body references,
1293/// recorded by [`body::BodyResult::referenced_globals`] regardless of
1294/// whether a real globals map was supplied — this call passes an empty one,
1295/// exactly [`call_edges`]'s "discard the computed types, keep the
1296/// structural fact" shape. `brink-db` resolves each returned id via
1297/// `signature_query` and hands the walk a small narrow `BTreeMap` before the
1298/// *real* solve runs (two walks: this scan, then [`solve_scc`] — see the
1299/// spec's Ruling 1 tradeoff note). Also the per-def global *read set* a
1300/// future T2 effect row needs — named and shaped for that reuse now, no
1301/// speculative machinery added.
1302///
1303/// `manifest` (T1d-2b, issue #774): same rationale as [`call_edges`]'s own
1304/// parameter — this pass discards every computed type too (only the
1305/// *referenced-def-id set* survives), so it can never change this
1306/// function's output; threaded so `brink-db`'s `referenced_globals_query`
1307/// shares one uniform per-def-seam shape with `call_edges_query`/
1308/// `solve_scc_query`.
1309#[must_use]
1310pub fn referenced_globals(
1311    def: DefinitionId,
1312    declaring_file_hir: &[(FileId, &HirFile)],
1313    index: &SymbolIndex,
1314    resolutions: &ResolutionMap,
1315    manifest: Option<&HostManifest>,
1316) -> BTreeSet<DefinitionId> {
1317    let by_file = index_resolutions_by_file(resolutions);
1318    let defs = collect_defs(declaring_file_hir, index);
1319    let Some(d) = defs.iter().find(|d| d.id == def) else {
1320        return BTreeSet::new();
1321    };
1322    let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
1323    let empty_inferable: BTreeSet<DefinitionId> = BTreeSet::new();
1324    let ctx = ProjectCtx::new(index, &empty_globals, &by_file, &empty_inferable, manifest);
1325    let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
1326    let body_ctx = ctx.body_ctx(d, &no_sigs);
1327    infer_def_body(d, &body_ctx).referenced_globals
1328}
1329
1330/// T2-1 (docs/effects-spec.md §2/§4, issue #860 — `def_effect_atoms(def)`).
1331/// One def's raw effect atoms: the read set (VAR/CONST globals read), the
1332/// write set (assignment targets resolving to a VAR/CONST), the call-kind set
1333/// (`EXTERNAL` names directly called), the inferable direct-call edges the
1334/// effect fixpoint follows, the fn-value creation targets (Fork A, issue
1335/// #1726 — `EffectAtoms::creates_fn_values`), and whether the body calls
1336/// through a function value it cannot trace (→ pessimal). Harvested by the
1337/// exact same body walk
1338/// [`referenced_globals`]/[`call_edges`] drive — the read set here *is*
1339/// FG-2.1's `referenced_globals`, and the direct-call edges are `call_edges`'s
1340/// set — so no new walk shape is introduced, only the per-def atom bundle T2
1341/// needs assembled from one pass.
1342///
1343/// **Narrowed inputs** mirror [`call_edges`] exactly: `declaring_file_hir`
1344/// need only cover `def`'s own file; `inferable` is caller-supplied
1345/// (index-sourced) so a resolved call target in a *different* file is still
1346/// classified as an edge, not a stray external. `manifest` is threaded for the
1347/// same uniform-seam reason — it can never change the *structural* atom sets
1348/// this discards every computed type to keep.
1349#[must_use]
1350pub fn def_effect_atoms(
1351    def: DefinitionId,
1352    declaring_file_hir: &[(FileId, &HirFile)],
1353    index: &SymbolIndex,
1354    resolutions: &ResolutionMap,
1355    inferable: &BTreeSet<DefinitionId>,
1356    manifest: Option<&HostManifest>,
1357) -> EffectAtoms {
1358    let by_file = index_resolutions_by_file(resolutions);
1359    let defs = collect_defs(declaring_file_hir, index);
1360    let Some(d) = defs.iter().find(|d| d.id == def) else {
1361        return EffectAtoms::default();
1362    };
1363    let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
1364    let ctx = ProjectCtx::new(index, &empty_globals, &by_file, inferable, manifest);
1365    let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
1366    let body_ctx = ctx.body_ctx(d, &no_sigs);
1367    let result = infer_def_body(d, &body_ctx);
1368    EffectAtoms {
1369        reads: result.referenced_globals,
1370        writes: result.effect_writes,
1371        calls: result.external_calls,
1372        direct_calls: result.calls,
1373        creates_fn_values: result.created_fn_values,
1374        opaque: result.effect_opaque,
1375        emits: result.effect_emits,
1376        tags: result.effect_tags,
1377        faults: result.effect_faults,
1378        faults_refined: result.effect_faults_refined,
1379        param_holes: result.param_holes,
1380        call_fn_args: result.call_fn_args,
1381    }
1382}
1383
1384/// T2-1 (docs/effects-spec.md §4, issue #860 — the whole-project effect row
1385/// table). Mirrors [`infer_project`]'s shape for effects: harvest every
1386/// inferable def's atoms, build the same call graph off the direct-call edges,
1387/// solve every SCC batch in condensation order with [`solve_scc_effects`]
1388/// (accumulating each finalized batch's rows as `known_rows` for its
1389/// successors). A pure function of already-computed inputs — the direct-call
1390/// pure-function callers and the property tests use it; `brink-db`'s per-SCC
1391/// `effects_scc_query` reproduces the same fold incrementally.
1392#[must_use]
1393pub fn effects_project(
1394    files: &[(FileId, &HirFile)],
1395    index: &SymbolIndex,
1396    resolutions: &ResolutionMap,
1397    manifest: Option<&HostManifest>,
1398) -> BTreeMap<DefinitionId, EffectRow> {
1399    let defs = collect_defs(files, index);
1400    let inferable: BTreeSet<DefinitionId> = defs.iter().map(|d| d.id).collect();
1401
1402    // Harvest each def's atoms once; the direct-call edges double as the call
1403    // graph the SCC batching needs.
1404    let atoms: BTreeMap<DefinitionId, EffectAtoms> = defs
1405        .iter()
1406        .map(|d| {
1407            (
1408                d.id,
1409                def_effect_atoms(d.id, files, index, resolutions, &inferable, manifest),
1410            )
1411        })
1412        .collect();
1413
1414    let mut graph = CallGraph::new();
1415    for (&id, a) in &atoms {
1416        graph.add_node(id);
1417        // Fork A (issue #1726): fn-value creation sites are call-graph edges
1418        // alongside the direct calls — structurally harvested, so no row is
1419        // ever consulted to build this graph. `creates_fn_values` is a subset
1420        // of `direct_calls` today (the same walk records both at a `#fn`
1421        // literal), but *this monolithic path* (`effects_project`, not the
1422        // salsa `call_graph_query` the IDE/`brink check`/@brink-lang/web
1423        // actually run — that graph is built from `call_edges_query`/
1424        // `direct_calls` alone and never reads `creates_fn_values`) names it
1425        // explicitly so batching here does not silently depend on that
1426        // coincidence. The salsa path deliberately still relies on the
1427        // subset property; `every_fn_value_creation_target_is_also_a_call_graph_edge`
1428        // (below) is its guard.
1429        for &callee in a.direct_calls.iter().chain(&a.creates_fn_values) {
1430            graph.add_edge(id, callee);
1431        }
1432    }
1433    let batches = topo_order(&graph);
1434
1435    let mut rows: BTreeMap<DefinitionId, EffectRow> = BTreeMap::new();
1436    for batch in &batches {
1437        // `solve_scc_effects` reads finalized predecessor rows out of
1438        // `known_rows`; every earlier batch is already folded in, so passing
1439        // the whole accumulated `rows` is exactly the condensation-predecessor
1440        // set (plus already-solved siblings, harmless — a batch never edges
1441        // back into a later one).
1442        let solved = solve_scc_effects(batch, &atoms, &rows);
1443        rows.extend(solved);
1444    }
1445    rows
1446}
1447
1448/// Solve exactly one SCC batch (FG-2, issue #631 — `solve_scc(SccId)`).
1449///
1450/// `known_sigs` must already carry the finalized signature of every def
1451/// *outside* `batch` that a member of `batch` calls — in practice, every def
1452/// in every condensation-predecessor SCC. `brink-db`'s `solve_scc_query`
1453/// gets these by recursively reading its own dependency SCCs'
1454/// `solve_scc_query` results first; the condensation is a DAG (SCCs are
1455/// maximal by construction), so that recursion is always acyclic — no salsa
1456/// cycles anywhere (Fork 1 ruling, design doc §8).
1457///
1458/// **Narrowed inputs (FG-2.1, issue #638, Ruling 2b/Ruling 1).** `defs` is
1459/// caller-supplied (built from per-def `def_body_query` results — only
1460/// `batch`'s own members' declaring files are ever read); `globals` is the
1461/// small narrow map `brink-db` built from every member's
1462/// [`referenced_globals`] pre-scan, resolved through `signature_query`
1463/// (never [`collect_globals`]'s whole-project scan); `inferable` is
1464/// index-sourced ([`inferable_defs_from_index`]). None of this changes the
1465/// fixpoint mechanics below — only how the read-only context feeding it is
1466/// assembled, and how narrow the salsa dependency edges recording that
1467/// assembly turn out to be.
1468///
1469/// `manifest` (T1d-2b, issue #774): the registered host manifest, threaded
1470/// through to `ProjectCtx` so a `Handle<K>` param/return/temp annotation
1471/// resolves to `Ty::Handle(K)` here too — this is the seam that makes
1472/// strict-mode handle-kind rejection reachable end-to-end (docs/t1d-spec.md
1473/// §3, the #767 acceptance criterion): once two locals of different
1474/// declared handle kinds are unified together (e.g. compared or
1475/// reassigned), the #627 lattice already folds them to `Ty::Conflicted`,
1476/// which `strict::check`'s existing `E066` classification reports — this
1477/// function is what was missing to let a genuine `Ty::Handle` ever reach
1478/// that lattice from body-usage inference at all. `brink-db`'s
1479/// `solve_scc_query` reads it off `project.analysis_options(db)`, the same
1480/// coarse project-wide dependency shape `per_file_diagnostics_query`
1481/// already reads `host_manifest` at.
1482///
1483/// **`EXTERNAL` call-site checking (issue #786; widened by issue #805 to
1484/// scalar semantic types and inline-doc-only bindings).** `known_sigs` is
1485/// also seeded (idempotently, every call — cheap index+manifest+doc scan, no
1486/// HIR) with [`collect_external_sigs`]'s declaration-derived signatures
1487/// before this batch solves, so a call to a manifest-registered or
1488/// inline-doc-only `EXTERNAL` types its arguments (and its return value,
1489/// wherever the call expression is used) against the binding's declared
1490/// types through the exact same [`body::BodyCtx::observe`] path a
1491/// knot/stitch call already uses — same #627 `Ty::Conflicted` lattice, same
1492/// `E066` report, no parallel checking surface. `index`/`manifest` are both
1493/// already read by this function for every other reason above; `inline_docs`
1494/// (issue #805) is the project-wide merged `///` doc-comment map
1495/// (`brink-db`'s `inline_docs_query`, the same memo `external_meta_query`
1496/// already reads it from), so this adds exactly one new salsa dependency
1497/// edge on `brink-db`'s `solve_scc_query` side — the same coarse,
1498/// range-free, `Eq`-cutoff shape `inline_docs_query` already gives every
1499/// other reader.
1500///
1501/// **Does not itself return an `EXTERNAL`'s signature (issue #1921).**
1502/// `batch` never contains an `EXTERNAL` — [`inferable_defs_from_index`]
1503/// filters the index to `SymbolKind::Knot | SymbolKind::Stitch` only — so
1504/// the returned `signatures` map (filtered to `batch`'s own members, see
1505/// below) never carries one, even though `known_sigs` is seeded with every
1506/// external's signature above. `brink-db`'s `type_inference_query`
1507/// re-merges [`collect_external_sigs`]'s seed into its own aggregated
1508/// `InferenceResult::signatures` once, after collecting every SCC's own
1509/// members' signatures from this function — not per-SCC here — so an
1510/// external's signature is exposed exactly once regardless of how many
1511/// SCCs a project has, instead of every `solve_scc_query` memo duplicating
1512/// the whole external-signature map.
1513#[must_use]
1514#[expect(
1515    clippy::too_many_arguments,
1516    reason = "the FG-2 per-SCC solve boundary (issue #631) — each parameter is an \
1517              independently-narrowed input `brink-db`'s solve_scc_query assembles from its \
1518              own per-def salsa queries; bundling them into a struct would just move the same \
1519              shape one level down for no clarity gain, and this is the one call site (the \
1520              salsa wrapper) plus tests, not a widely-called API"
1521)]
1522pub fn solve_scc(
1523    batch: &BTreeSet<DefinitionId>,
1524    defs: &[Def<'_>],
1525    index: &SymbolIndex,
1526    resolutions: &ResolutionMap,
1527    globals: &BTreeMap<DefinitionId, Ty>,
1528    inferable: &BTreeSet<DefinitionId>,
1529    mut known_sigs: BTreeMap<DefinitionId, InferredSig>,
1530    manifest: Option<&HostManifest>,
1531    inline_docs: &BTreeMap<(SymbolKind, String), DocBlock>,
1532) -> (
1533    BTreeMap<DefinitionId, InferredSig>,
1534    BTreeMap<DefinitionId, BodyTypes>,
1535) {
1536    known_sigs.extend(collect_external_sigs(index, manifest, inline_docs));
1537    let by_file = index_resolutions_by_file(resolutions);
1538    let by_id: BTreeMap<DefinitionId, &Def<'_>> = defs.iter().map(|d| (d.id, d)).collect();
1539    let ctx = ProjectCtx::new(index, globals, &by_file, inferable, manifest);
1540
1541    let bodies = solve_one_batch(batch, &by_id, &ctx, &mut known_sigs);
1542    let signatures: BTreeMap<DefinitionId, InferredSig> = batch
1543        .iter()
1544        .filter_map(|id| known_sigs.get(id).map(|sig| (*id, sig.clone())))
1545        .collect();
1546    (signatures, bodies)
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551    use super::*;
1552    use brink_ir::lower;
1553
1554    fn build(src: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
1555        let parsed = brink_syntax::parse(src);
1556        let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
1557        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
1558        let (resolutions, _diag) =
1559            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
1560        (hir, (*index).clone(), (*resolutions).clone())
1561    }
1562
1563    /// [`build`], but with `FileId(0)` given an explicit **declared** module
1564    /// (issue #2233 review finding: `body_ctx`'s `referrer_module` threading
1565    /// was never exercised by any test — every `BodyCtx` literal below and
1566    /// every `resolve.rs` test passes the module as a hardcoded literal
1567    /// rather than reading it off a real `ProjectCtx::body_ctx` call). Mirrors
1568    /// `brink-analyzer::manifest`'s own declared-module test shape
1569    /// (`ResolvedModule { declared: true, .. }` inserted into a `ModuleMap`).
1570    fn build_with_module(src: &str, module_name: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
1571        let parsed = brink_syntax::parse(src);
1572        let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
1573        let mut modules = crate::ModuleMap::new();
1574        modules.insert(
1575            FileId(0),
1576            crate::ResolvedModule {
1577                name: module_name.to_string(),
1578                declared: true,
1579                was: None,
1580            },
1581        );
1582        let (index, _diag) = crate::symbol_index_with_modules(
1583            &[(FileId(0), &manifest)],
1584            &modules,
1585            crate::Dialect::Brink,
1586            false,
1587        );
1588        let scope = crate::ImportScope::new(Some(module_name.to_string()), &hir.imports);
1589        let (resolutions, _diag) = crate::resolve(FileId(0), &manifest, &index, &scope);
1590        (hir, (*index).clone(), (*resolutions).clone())
1591    }
1592
1593    /// [`build`], plus the project-wide merged `///` doc map (issue #805 —
1594    /// the inline-doc-only `collect_external_sigs` source, mirroring
1595    /// `whole_project_diagnostics`'s own `collect_inline_docs` call).
1596    fn build_with_docs(
1597        src: &str,
1598    ) -> (
1599        HirFile,
1600        SymbolIndex,
1601        ResolutionMap,
1602        BTreeMap<(SymbolKind, String), DocBlock>,
1603    ) {
1604        let parsed = brink_syntax::parse(src);
1605        let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
1606        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
1607        let (resolutions, _diag) =
1608            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
1609        let inline_docs = crate::project_inline_docs(&[(FileId(0), &manifest)]);
1610        (hir, (*index).clone(), (*resolutions).clone(), inline_docs)
1611    }
1612
1613    fn sig_of<'a>(result: &'a InferenceResult, index: &SymbolIndex, name: &str) -> &'a InferredSig {
1614        let id = index
1615            .by_name
1616            .get(name)
1617            .and_then(|ids| ids.first())
1618            .copied()
1619            .expect("no def with this name");
1620        result
1621            .signatures
1622            .get(&id)
1623            .expect("no inferred signature for this def")
1624    }
1625
1626    #[test]
1627    fn param_type_inferred_from_arithmetic_use() {
1628        // A knot whose param is used arithmetically against an int literal.
1629        let (hir, index, res) = build("=== heal(hp) ===\n~ temp x = hp + 1\n-> DONE\n");
1630        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1631        let sig = sig_of(&result, &index, "heal");
1632        assert_eq!(sig.params, vec![Ty::Int]);
1633    }
1634
1635    #[test]
1636    fn param_type_inferred_from_comparison_with_float_literal() {
1637        let (hir, index, res) = build("=== spend(gold) ===\n{gold > 1.5:\n  ok\n}\n-> DONE\n");
1638        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1639        let sig = sig_of(&result, &index, "spend");
1640        assert_eq!(sig.params, vec![Ty::Float]);
1641    }
1642
1643    #[test]
1644    fn floating_stitch_body_is_inferred() {
1645        // A *floating* stitch — `= name`, declared before any `== knot ==`
1646        // header — lowers into `hir.knots` (with `NodeClass::Stitch` provenance) but
1647        // is declared `SymbolKind::Stitch` with a bare name, not
1648        // `SymbolKind::Knot`. Before #626, `collect_defs` always looked the
1649        // entry up as `SymbolKind::Knot`, the lookup silently failed, and
1650        // this def never made it into `defs` — no signature, no body types,
1651        // total silent skip.
1652        let (hir, index, res) = build("= heal(hp)\n~ temp x = hp + 1\n-> DONE\n");
1653        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1654        let sig = sig_of(&result, &index, "heal");
1655        assert_eq!(sig.params, vec![Ty::Int]);
1656    }
1657
1658    #[test]
1659    fn floating_stitch_coexists_with_real_knot_and_its_nested_stitch() {
1660        // Regression guard for the fix itself: distinguishing floating
1661        // stitches (`NodeClass::Stitch` provenance) from real knots
1662        // (`NodeClass::Knot` provenance) in `collect_defs` must not disturb the
1663        // existing, already-working real-knot / nested-stitch lookup path.
1664        let (hir, index, res) = build(
1665            "= intro(hp)\n~ temp x = hp + 1\n-> DONE\n\
1666             === knot_a(gold) ===\n{gold > 1.5:\n  ok\n}\n-> stitch_a ->\n\
1667             = stitch_a(silver)\n~ temp y = silver + 1\n-> DONE\n",
1668        );
1669        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1670        assert_eq!(sig_of(&result, &index, "intro").params, vec![Ty::Int]);
1671        assert_eq!(sig_of(&result, &index, "knot_a").params, vec![Ty::Float]);
1672        let stitch_a_id = index
1673            .by_name
1674            .get("knot_a.stitch_a")
1675            .and_then(|ids| ids.first())
1676            .copied()
1677            .expect("no def for knot_a.stitch_a");
1678        let stitch_a_sig = result
1679            .signatures
1680            .get(&stitch_a_id)
1681            .expect("no inferred signature for knot_a.stitch_a");
1682        assert_eq!(stitch_a_sig.params, vec![Ty::Int]);
1683    }
1684
1685    /// Issue #2233 review finding (rule 19q/20a): `ProjectCtx::body_ctx`'s
1686    /// `referrer_module` threading was untested — every `BodyCtx` literal in
1687    /// `body.rs`'s own test module and every `resolve.rs` test passes the
1688    /// module as a hardcoded literal/argument, so replacing `body_ctx`'s
1689    /// computed expression with a hardcoded `None` left the whole suite
1690    /// green. This exercises the real `ProjectCtx::new`/`body_ctx` call path
1691    /// for an ordinary, indexed def declared inside `std…`.
1692    #[test]
1693    fn body_ctx_threads_referrer_module_for_a_real_def() {
1694        let (hir, index, _res) = build_with_module(
1695            "=== heal(hp) ===\n~ temp x = hp + 1\n-> DONE\n",
1696            "std::conventions::screenplay",
1697        );
1698        let id = index
1699            .by_name
1700            .get("heal")
1701            .and_then(|ids| ids.first())
1702            .copied()
1703            .expect("heal def indexed");
1704        let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
1705        let by_file: BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>> = BTreeMap::new();
1706        let inferable: BTreeSet<DefinitionId> = [id].into_iter().collect();
1707        let ctx = ProjectCtx::new(&index, &empty_globals, &by_file, &inferable, None);
1708        let defs = collect_defs(&[(FileId(0), &hir)], &index);
1709        let def = defs.iter().find(|d| d.id == id).expect("def found");
1710        let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
1711        let body_ctx = ctx.body_ctx(def, &no_sigs);
1712        assert_eq!(
1713            body_ctx.referrer_module,
1714            Some("std::conventions::screenplay"),
1715            "a real def's referrer_module must come from its own declared module"
1716        );
1717    }
1718
1719    /// Issue #2233 review finding: the synthetic root-content def
1720    /// (`collect_defs`, issue #1903 — minted for `hir.root_content`'s own
1721    /// walk) has no `SymbolIndex` entry of its own. Before this fix,
1722    /// `body_ctx`'s `index.symbols.get(&def.id)` lookup always missed for
1723    /// it, silently leaving `referrer_module: None` even for a file declared
1724    /// inside `std…` — exactly the #2233 disagreement this PR closes.
1725    /// `ProjectCtx::file_modules` keys by `def.file` instead, which this def
1726    /// carries just like a real one, so it must resolve too.
1727    #[test]
1728    fn body_ctx_threads_referrer_module_for_the_synthetic_root_content_def() {
1729        // The file also needs at least one *named* declaration (`knot_a`
1730        // here) — `ProjectCtx::file_modules` derives a file's module from
1731        // any symbol the index already has for it, so a file with zero
1732        // indexed symbols at all has no entry to derive from at all (the
1733        // same "absent data reads as empty" default every other
1734        // module-blind path in this module uses, not a regression this fix
1735        // introduces). The overwhelmingly common real-world shape this
1736        // fixes — a std file's own top-level weave calling a UFCS free
1737        // function — always has at least one such declaration.
1738        let (hir, index, _res) = build_with_module(
1739            "Hello.\n-> DONE\n=== knot_a ===\nworld\n-> DONE\n",
1740            "std::conventions::screenplay",
1741        );
1742        assert!(
1743            !hir.root_content.stmts.is_empty(),
1744            "fixture must have non-empty root content to mint the synthetic def"
1745        );
1746        let empty_globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
1747        let by_file: BTreeMap<FileId, BTreeMap<(u32, u32), DefinitionId>> = BTreeMap::new();
1748        let inferable: BTreeSet<DefinitionId> = BTreeSet::new();
1749        let ctx = ProjectCtx::new(&index, &empty_globals, &by_file, &inferable, None);
1750        let defs = collect_defs(&[(FileId(0), &hir)], &index);
1751        let synthetic = defs
1752            .iter()
1753            .find(|d| !index.symbols.contains_key(&d.id))
1754            .expect("synthetic root-content def present");
1755        let no_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
1756        let body_ctx = ctx.body_ctx(synthetic, &no_sigs);
1757        assert_eq!(
1758            body_ctx.referrer_module,
1759            Some("std::conventions::screenplay"),
1760            "the synthetic root-content def's referrer_module must still resolve, keyed by \
1761             file rather than the def's own (absent) index entry"
1762        );
1763    }
1764
1765    #[test]
1766    fn unused_param_is_unknown_and_legal() {
1767        let (hir, index, res) = build("=== noop(x) ===\nHello.\n-> DONE\n");
1768        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1769        let sig = sig_of(&result, &index, "noop");
1770        assert_eq!(sig.params, vec![Ty::Unknown]);
1771    }
1772
1773    /// Issue #1532 (#1501 review finding 3): the #1484 `remove`/`remove_at`
1774    /// split's advertised latent fix — the array leg no longer narrows its
1775    /// *index* argument against the array's *element* type (wrong for an
1776    /// index; the pre-split shared `remove` code did this) — shipped with
1777    /// no regression test. `arr` is a `temp` — a `VAR` would work equally
1778    /// well since issue #1540 gave globals a full-fidelity `Sig::value_ty`,
1779    /// but the `temp` spelling is what this test was written against — so
1780    /// its `Ty::Array(String)`
1781    /// element type is genuinely in hand at the call site. If `remove_at`'s
1782    /// index arm regressed to narrowing against it, `i` would come out
1783    /// `Ty::String` here instead of staying `Unknown` (`i` is otherwise
1784    /// unused — `unused_param_is_unknown_and_legal`'s baseline). Mirrors
1785    /// `insert`'s array leg, which is also index-typed and was never
1786    /// narrowed (`infer::body`'s `"insert"` arm only narrows the map k/v
1787    /// pair).
1788    #[test]
1789    fn remove_at_index_arg_does_not_narrow_against_the_array_element_type() {
1790        let (hir, index, res) = build(
1791            "=== function drop_at(i) ===\n~ {\n    temp arr = #[\"a\", \"b\", \"c\"]\n    remove_at(arr, i)\n}\n~ return 0\n",
1792        );
1793        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1794        let drop_at_id = index
1795            .by_name
1796            .get("drop_at")
1797            .and_then(|ids| ids.first())
1798            .copied()
1799            .expect("drop_at");
1800        let body = result.bodies.get(&drop_at_id).expect("drop_at body");
1801        assert_eq!(
1802            body.locals.get("arr"),
1803            Some(&Ty::Array(Box::new(Ty::String))),
1804            "fixture sanity: arr must actually be known as an array of strings, or this test \
1805             can't distinguish the fix from the bug it guards"
1806        );
1807        let sig = sig_of(&result, &index, "drop_at");
1808        assert_eq!(
1809            sig.params,
1810            vec![Ty::Unknown],
1811            "remove_at's index argument must not narrow against the array's element type"
1812        );
1813    }
1814
1815    #[test]
1816    fn return_type_inferred_from_return_statement() {
1817        let (hir, index, res) = build("=== function double(x) ===\n~ return x + x\n");
1818        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1819        let sig = sig_of(&result, &index, "double");
1820        // `x` only ever appears added to itself — Unknown stays Unknown
1821        // under `unify(Unknown, Unknown) == Unknown`; the *return type*
1822        // still comes out Unknown too (nothing ever pins `x` concrete).
1823        assert_eq!(sig.return_ty, Ty::Unknown);
1824    }
1825
1826    /// [`build`]'s native-frontend twin — `InfixOp::Coalesce` (B1, issue
1827    /// #1460) is produced only by `hir::lower_native`, so the coalescing
1828    /// feedback test below (unlike every other test in this module) must
1829    /// parse through the native frontend rather than `brink_syntax`.
1830    fn build_native(src: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
1831        let parse = brink_syntax_native::parse(src);
1832        assert!(
1833            parse.errors().is_empty(),
1834            "fixture must parse cleanly: {:?}",
1835            parse.errors()
1836        );
1837        let tree = parse.tree();
1838        let (hir, manifest, _diag) = brink_ir::hir::lower_native::lower(FileId(0), &tree);
1839        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
1840        let (resolutions, _diag) =
1841            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
1842        (hir, (*index).clone(), (*resolutions).clone())
1843    }
1844
1845    /// Review finding on PR #1469/#1460: the `InfixOp::Coalesce` arm's
1846    /// one-directional `observe()` feedback (`infer::body::InferPass::
1847    /// infer_infix`'s doc: "so if `lhs` is a bare param/temp path, `rhs`'s
1848    /// already-inferred type tells us the shape to expect") was asserted in
1849    /// the PR body but never pinned by a test. `x` is only ever used as
1850    /// `x or 0` — `rhs` is `int`, not itself `Option`, so the collapse-form
1851    /// branch feeds `Option[int]` back onto `x`, rather than `x` leaking
1852    /// `Unknown` (T1c's un-narrowed-Unknown posture every other bare-unused
1853    /// param hits — see `unused_param_is_unknown_and_legal` above).
1854    #[test]
1855    fn coalesce_lhs_param_narrows_to_option_of_the_rhs_type() {
1856        let (hir, index, res) = build_native("fn f(x) {\n  return x or 0;\n}\n");
1857        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1858        let sig = sig_of(&result, &index, "f");
1859        assert_eq!(sig.params, vec![Ty::Option(Box::new(Ty::Int))]);
1860    }
1861
1862    #[test]
1863    fn call_site_propagates_callee_param_type_to_caller_local() {
1864        let (hir, index, res) = build(
1865            "=== main ===\n~ temp v = 1\n~ use_it(v)\n-> DONE\n=== use_it(n) ===\n{n > 2.5:\n  big\n}\n-> DONE\n",
1866        );
1867        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1868        let use_it = sig_of(&result, &index, "use_it");
1869        assert_eq!(use_it.params, vec![Ty::Float]);
1870        // `main`'s own local `v` isn't a param, so we check it via `bodies`.
1871        let main_id = index
1872            .by_name
1873            .get("main")
1874            .and_then(|ids| ids.first())
1875            .copied()
1876            .expect("main");
1877        let main_body = result.bodies.get(&main_id).expect("main body");
1878        assert_eq!(main_body.locals.get("v"), Some(&Ty::Float));
1879    }
1880
1881    // ─── `EXTERNAL` call-site checking (issue #786) ─────────────────────
1882
1883    fn audio_manifest_with_external(param_kind: &str) -> brink_ir::HostManifest {
1884        brink_ir::HostManifest {
1885            markup: Vec::new(),
1886            types: vec![
1887                brink_ir::SemanticTypeDef {
1888                    name: "AudioInstance".to_string(),
1889                    base: brink_ir::BaseType::Handle,
1890                    constraint: None,
1891                    values: None,
1892                    widget: None,
1893                },
1894                brink_ir::SemanticTypeDef {
1895                    name: "Timer".to_string(),
1896                    base: brink_ir::BaseType::Handle,
1897                    constraint: None,
1898                    values: None,
1899                    widget: None,
1900                },
1901            ],
1902            externals: vec![brink_ir::ManifestExternal {
1903                name: "play_sound".to_string(),
1904                params: vec![brink_ir::ManifestParam {
1905                    name: "inst".to_string(),
1906                    ty: brink_ir::TypeRef(param_kind.to_string()),
1907                }],
1908                returns: brink_ir::TypeRef::default(),
1909                kind: brink_ir::ExternalKind::default(),
1910                doc: None,
1911                widgets: Vec::new(),
1912                path: Vec::new(),
1913            }],
1914        }
1915    }
1916
1917    /// The #786 mechanism, isolated: a manifest-registered `EXTERNAL`'s
1918    /// declared `Handle<K>` param type propagates into `known_sigs` exactly
1919    /// like a knot/stitch callee's declared param type does
1920    /// ([`call_site_propagates_callee_param_type_to_caller_local`]'s own
1921    /// pattern) — a caller's local passed as the argument picks up the
1922    /// binding's declared kind.
1923    #[test]
1924    fn external_call_propagates_declared_handle_kind_to_caller_local() {
1925        let (hir, index, res) = build(
1926            "EXTERNAL play_sound(inst)\n=== main ===\n~ temp s = get_sound(1)\n\
1927             ~ play_sound(s)\n-> DONE\n=== function get_sound(id): Handle<AudioInstance> ===\n~ return id\n",
1928        );
1929        let manifest = audio_manifest_with_external("AudioInstance");
1930        let result = infer_project(
1931            &[(FileId(0), &hir)],
1932            &index,
1933            &res,
1934            Some(&manifest),
1935            &BTreeMap::new(),
1936        );
1937        let main_id = index
1938            .by_name
1939            .get("main")
1940            .and_then(|ids| ids.first())
1941            .copied()
1942            .expect("main");
1943        let main_body = result.bodies.get(&main_id).expect("main body");
1944        assert_eq!(
1945            main_body.locals.get("s"),
1946            Some(&Ty::Handle("AudioInstance".to_string())),
1947            "s picks up its own declared return kind cleanly: {main_body:?}"
1948        );
1949    }
1950
1951    /// Positive case: a local declared with one handle kind, passed as the
1952    /// argument to a binding declared for a *different* kind, folds to
1953    /// `Ty::Conflicted` at the call site through `observe`/`unify` — the
1954    /// same #627 lattice a mismatched knot/stitch call argument already
1955    /// used, no parallel checking surface (`strict.rs`'s
1956    /// `external_call_cross_kind_argument_is_conflicted_under_strict` pins
1957    /// the resulting `E066` diagnostic end to end).
1958    #[test]
1959    fn external_call_with_cross_kind_argument_conflicts_the_caller_local() {
1960        let (hir, index, res) = build(
1961            "EXTERNAL play_sound(inst)\n=== main ===\n~ temp t = get_timer(1)\n\
1962             ~ play_sound(t)\n-> DONE\n=== function get_timer(id): Handle<Timer> ===\n~ return id\n",
1963        );
1964        let manifest = audio_manifest_with_external("AudioInstance");
1965        let result = infer_project(
1966            &[(FileId(0), &hir)],
1967            &index,
1968            &res,
1969            Some(&manifest),
1970            &BTreeMap::new(),
1971        );
1972        let main_id = index
1973            .by_name
1974            .get("main")
1975            .and_then(|ids| ids.first())
1976            .copied()
1977            .expect("main");
1978        let main_body = result.bodies.get(&main_id).expect("main body");
1979        assert_eq!(
1980            main_body.locals.get("t"),
1981            Some(&Ty::Conflicted),
1982            "t is Timer-kinded but play_sound declares AudioInstance: {main_body:?}"
1983        );
1984    }
1985
1986    /// No manifest registered at all: an `EXTERNAL` call contributes no
1987    /// signature (`collect_external_sigs` degrades to empty, same posture as
1988    /// every other manifest-driven check) — the call types `Ty::Unknown`,
1989    /// exactly today's byte-identical behavior. Pins the "gradual mode is
1990    /// unaffected" half of the #786 acceptance criterion at the inference
1991    /// level (strict mode itself never even runs without `types = strict`).
1992    #[test]
1993    fn external_call_with_no_manifest_stays_unknown() {
1994        let (hir, index, res) = build(
1995            "EXTERNAL play_sound(inst)\n=== main ===\n~ temp t = 1\n~ play_sound(t)\n-> DONE\n",
1996        );
1997        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
1998        let main_id = index
1999            .by_name
2000            .get("main")
2001            .and_then(|ids| ids.first())
2002            .copied()
2003            .expect("main");
2004        let main_body = result.bodies.get(&main_id).expect("main body");
2005        // `t` is pinned Int by its own `= 1` initializer, unaffected by the
2006        // unchecked external call.
2007        assert_eq!(main_body.locals.get("t"), Some(&Ty::Int));
2008    }
2009
2010    /// An `EXTERNAL` with no matching registered manifest entry AND no
2011    /// inline `///` doc — truly undeclared — contributes no signature
2012    /// either, same conservative "absent data reads as no signature"
2013    /// contract as every other lookup miss in this module. (Issue #805
2014    /// widens the *inline-doc-only* case — a registered `///` doc with no
2015    /// matching `ManifestExternal` — to contribute a real signature; see
2016    /// `inline_only_external_*` below for that case specifically.)
2017    #[test]
2018    fn external_call_with_unregistered_name_stays_unknown() {
2019        let (hir, index, res) = build(
2020            "EXTERNAL other_call(inst)\n=== main ===\n~ temp t = 1\n~ other_call(t)\n-> DONE\n",
2021        );
2022        let manifest = audio_manifest_with_external("AudioInstance");
2023        let result = infer_project(
2024            &[(FileId(0), &hir)],
2025            &index,
2026            &res,
2027            Some(&manifest),
2028            &BTreeMap::new(),
2029        );
2030        let main_id = index
2031            .by_name
2032            .get("main")
2033            .and_then(|ids| ids.first())
2034            .copied()
2035            .expect("main");
2036        let main_body = result.bodies.get(&main_id).expect("main body");
2037        assert_eq!(main_body.locals.get("t"), Some(&Ty::Int));
2038    }
2039
2040    // ─── Issue #805: scalar semantic types, inline-only externals, and
2041    // return-position kind checking ─────────────────────────────────────
2042
2043    /// A manifest declaring a *scalar* semantic type (`switch_id`, `base:
2044    /// Int`) alongside the two handle kinds — the vocabulary
2045    /// `collect_external_sigs` now resolves param/return `TypeRef`s against
2046    /// uniformly, handle or scalar.
2047    fn manifest_with_scalar_and_handle_types() -> brink_ir::HostManifest {
2048        let mut manifest = audio_manifest_with_external("AudioInstance");
2049        manifest.types.push(brink_ir::SemanticTypeDef {
2050            name: "switch_id".to_string(),
2051            base: brink_ir::BaseType::Int,
2052            constraint: None,
2053            values: None,
2054            widget: None,
2055        });
2056        manifest
2057    }
2058
2059    /// Point (1): a manifest-registered `EXTERNAL`'s param declared with a
2060    /// *scalar* semantic type (not a `Handle<K>` kind) now resolves to its
2061    /// own `base` (`switch_id` -> `Ty::Int`) and propagates into the
2062    /// caller's local exactly like a `Handle<K>`-declared param already did
2063    /// (mirrors `external_call_propagates_declared_handle_kind_to_caller_local`).
2064    #[test]
2065    fn external_call_scalar_semantic_type_param_propagates_to_caller_local() {
2066        let mut manifest = manifest_with_scalar_and_handle_types();
2067        manifest.externals.push(brink_ir::ManifestExternal {
2068            name: "toggle".to_string(),
2069            params: vec![brink_ir::ManifestParam {
2070                name: "id".to_string(),
2071                ty: brink_ir::TypeRef("switch_id".to_string()),
2072            }],
2073            returns: brink_ir::TypeRef::default(),
2074            kind: brink_ir::ExternalKind::default(),
2075            doc: None,
2076            widgets: Vec::new(),
2077            path: Vec::new(),
2078        });
2079        let (hir, index, res) =
2080            build("EXTERNAL toggle(id)\n=== main ===\n~ temp s = 1\n~ toggle(s)\n-> DONE\n");
2081        let result = infer_project(
2082            &[(FileId(0), &hir)],
2083            &index,
2084            &res,
2085            Some(&manifest),
2086            &BTreeMap::new(),
2087        );
2088        let main_id = index
2089            .by_name
2090            .get("main")
2091            .and_then(|ids| ids.first())
2092            .copied()
2093            .expect("main");
2094        let main_body = result.bodies.get(&main_id).expect("main body");
2095        assert_eq!(
2096            main_body.locals.get("s"),
2097            Some(&Ty::Int),
2098            "s unifies cleanly against toggle's declared switch_id (base int): {main_body:?}"
2099        );
2100    }
2101
2102    /// Point (1), negative: a caller's local pinned to a *different*
2103    /// concrete type (string) than the binding's declared scalar semantic
2104    /// type (`switch_id`, base int) folds to `Ty::Conflicted` at the call
2105    /// site — the same #627 lattice a `Handle<K>` mismatch already used, no
2106    /// new diagnostic code.
2107    #[test]
2108    fn external_call_scalar_semantic_type_mismatch_conflicts_the_caller_local() {
2109        let mut manifest = manifest_with_scalar_and_handle_types();
2110        manifest.externals.push(brink_ir::ManifestExternal {
2111            name: "toggle".to_string(),
2112            params: vec![brink_ir::ManifestParam {
2113                name: "id".to_string(),
2114                ty: brink_ir::TypeRef("switch_id".to_string()),
2115            }],
2116            returns: brink_ir::TypeRef::default(),
2117            kind: brink_ir::ExternalKind::default(),
2118            doc: None,
2119            widgets: Vec::new(),
2120            path: Vec::new(),
2121        });
2122        let (hir, index, res) = build(
2123            "EXTERNAL toggle(id)\n=== main ===\n~ temp s = \"harbor\"\n~ toggle(s)\n-> DONE\n",
2124        );
2125        let result = infer_project(
2126            &[(FileId(0), &hir)],
2127            &index,
2128            &res,
2129            Some(&manifest),
2130            &BTreeMap::new(),
2131        );
2132        let main_id = index
2133            .by_name
2134            .get("main")
2135            .and_then(|ids| ids.first())
2136            .copied()
2137            .expect("main");
2138        let main_body = result.bodies.get(&main_id).expect("main body");
2139        assert_eq!(
2140            main_body.locals.get("s"),
2141            Some(&Ty::Conflicted),
2142            "s is a string but toggle declares switch_id (base int): {main_body:?}"
2143        );
2144    }
2145
2146    /// Point (2): an `EXTERNAL` documented *purely* via an inline `///
2147    /// @param` doc comment — no corresponding `ManifestExternal` entry at
2148    /// all in the registered manifest — now seeds a signature too
2149    /// (`collect_external_sigs`'s inline-doc merge). The manifest here only
2150    /// registers the `AudioInstance`/`Timer` handle-kind *vocabulary*
2151    /// (`types`), never a `play_sound` entry under `externals`.
2152    #[test]
2153    fn inline_only_external_param_type_propagates_to_caller_local() {
2154        let (hir, index, res, inline_docs) = build_with_docs(
2155            "/// @param inst {AudioInstance}\n\
2156             EXTERNAL play_sound(inst)\n\
2157             === main ===\n~ temp s = get_sound(1)\n~ play_sound(s)\n-> DONE\n\
2158             === function get_sound(id): Handle<AudioInstance> ===\n~ return id\n",
2159        );
2160        let manifest = brink_ir::HostManifest {
2161            markup: Vec::new(),
2162            types: vec![
2163                brink_ir::SemanticTypeDef {
2164                    name: "AudioInstance".to_string(),
2165                    base: brink_ir::BaseType::Handle,
2166                    constraint: None,
2167                    values: None,
2168                    widget: None,
2169                },
2170                brink_ir::SemanticTypeDef {
2171                    name: "Timer".to_string(),
2172                    base: brink_ir::BaseType::Handle,
2173                    constraint: None,
2174                    values: None,
2175                    widget: None,
2176                },
2177            ],
2178            externals: Vec::new(), // deliberately no `play_sound` entry
2179        };
2180        let result = infer_project(
2181            &[(FileId(0), &hir)],
2182            &index,
2183            &res,
2184            Some(&manifest),
2185            &inline_docs,
2186        );
2187        let main_id = index
2188            .by_name
2189            .get("main")
2190            .and_then(|ids| ids.first())
2191            .copied()
2192            .expect("main");
2193        let main_body = result.bodies.get(&main_id).expect("main body");
2194        assert_eq!(
2195            main_body.locals.get("s"),
2196            Some(&Ty::Handle("AudioInstance".to_string())),
2197            "s unifies cleanly against play_sound's inline-doc-declared AudioInstance: {main_body:?}"
2198        );
2199    }
2200
2201    /// Point (2), negative: same inline-doc-only `play_sound`, but the
2202    /// caller's local is declared a *different* handle kind (`Timer`) —
2203    /// folds to `Ty::Conflicted`, proving the inline-only signature is
2204    /// actually checked, not just recorded.
2205    #[test]
2206    fn inline_only_external_cross_kind_argument_conflicts_the_caller_local() {
2207        let (hir, index, res, inline_docs) = build_with_docs(
2208            "/// @param inst {AudioInstance}\n\
2209             EXTERNAL play_sound(inst)\n\
2210             === main ===\n~ temp t = get_timer(1)\n~ play_sound(t)\n-> DONE\n\
2211             === function get_timer(id): Handle<Timer> ===\n~ return id\n",
2212        );
2213        let manifest = brink_ir::HostManifest {
2214            markup: Vec::new(),
2215            types: vec![
2216                brink_ir::SemanticTypeDef {
2217                    name: "AudioInstance".to_string(),
2218                    base: brink_ir::BaseType::Handle,
2219                    constraint: None,
2220                    values: None,
2221                    widget: None,
2222                },
2223                brink_ir::SemanticTypeDef {
2224                    name: "Timer".to_string(),
2225                    base: brink_ir::BaseType::Handle,
2226                    constraint: None,
2227                    values: None,
2228                    widget: None,
2229                },
2230            ],
2231            externals: Vec::new(),
2232        };
2233        let result = infer_project(
2234            &[(FileId(0), &hir)],
2235            &index,
2236            &res,
2237            Some(&manifest),
2238            &inline_docs,
2239        );
2240        let main_id = index
2241            .by_name
2242            .get("main")
2243            .and_then(|ids| ids.first())
2244            .copied()
2245            .expect("main");
2246        let main_body = result.bodies.get(&main_id).expect("main body");
2247        assert_eq!(
2248            main_body.locals.get("t"),
2249            Some(&Ty::Conflicted),
2250            "t is Timer-kinded but play_sound's inline doc declares AudioInstance: {main_body:?}"
2251        );
2252    }
2253
2254    /// Point (3): return-position kind checking. `spawn_timer`'s
2255    /// *registered* return type is `Timer` (a handle kind) — assigning its
2256    /// result directly to a local already pinned `AudioInstance` (by a
2257    /// second call) must fold that local to `Ty::Conflicted`, proving the
2258    /// binding's declared *return* kind is checked, not just its params.
2259    /// (`external_call_propagates_declared_handle_kind_to_caller_local`
2260    /// already pins the positive return-position case implicitly, via
2261    /// `play_sound`'s *param* absorbing `get_sound`'s knot-return-annotated
2262    /// kind; this test isolates an `EXTERNAL`'s own declared return kind
2263    /// instead of a knot's.)
2264    #[test]
2265    fn external_call_return_position_kind_mismatch_conflicts_the_caller_local() {
2266        let mut manifest = audio_manifest_with_external("AudioInstance");
2267        manifest.externals.push(brink_ir::ManifestExternal {
2268            name: "spawn_timer".to_string(),
2269            params: Vec::new(),
2270            returns: brink_ir::TypeRef("Timer".to_string()),
2271            kind: brink_ir::ExternalKind::default(),
2272            doc: None,
2273            widgets: Vec::new(),
2274            path: Vec::new(),
2275        });
2276        let (hir, index, res) = build(
2277            "EXTERNAL play_sound(inst)\nEXTERNAL spawn_timer()\n\
2278             === main ===\n~ temp x = spawn_timer()\n~ play_sound(x)\n-> DONE\n",
2279        );
2280        let result = infer_project(
2281            &[(FileId(0), &hir)],
2282            &index,
2283            &res,
2284            Some(&manifest),
2285            &BTreeMap::new(),
2286        );
2287        let main_id = index
2288            .by_name
2289            .get("main")
2290            .and_then(|ids| ids.first())
2291            .copied()
2292            .expect("main");
2293        let main_body = result.bodies.get(&main_id).expect("main body");
2294        assert_eq!(
2295            main_body.locals.get("x"),
2296            Some(&Ty::Conflicted),
2297            "x is spawn_timer's declared Timer return, passed where play_sound declares \
2298             AudioInstance: {main_body:?}"
2299        );
2300    }
2301
2302    /// Point (3), positive: `spawn_timer`'s declared return kind matches the
2303    /// declared param kind it's immediately passed to — unifies cleanly, no
2304    /// escape.
2305    #[test]
2306    fn external_call_return_position_kind_match_unifies_cleanly() {
2307        let mut manifest = audio_manifest_with_external("AudioInstance");
2308        manifest.externals.push(brink_ir::ManifestExternal {
2309            name: "spawn_audio".to_string(),
2310            params: Vec::new(),
2311            returns: brink_ir::TypeRef("AudioInstance".to_string()),
2312            kind: brink_ir::ExternalKind::default(),
2313            doc: None,
2314            widgets: Vec::new(),
2315            path: Vec::new(),
2316        });
2317        let (hir, index, res) = build(
2318            "EXTERNAL play_sound(inst)\nEXTERNAL spawn_audio()\n\
2319             === main ===\n~ temp x = spawn_audio()\n~ play_sound(x)\n-> DONE\n",
2320        );
2321        let result = infer_project(
2322            &[(FileId(0), &hir)],
2323            &index,
2324            &res,
2325            Some(&manifest),
2326            &BTreeMap::new(),
2327        );
2328        let main_id = index
2329            .by_name
2330            .get("main")
2331            .and_then(|ids| ids.first())
2332            .copied()
2333            .expect("main");
2334        let main_body = result.bodies.get(&main_id).expect("main body");
2335        assert_eq!(
2336            main_body.locals.get("x"),
2337            Some(&Ty::Handle("AudioInstance".to_string())),
2338            "x is spawn_audio's declared AudioInstance return, matching play_sound's own \
2339             declared param kind: {main_body:?}"
2340        );
2341    }
2342
2343    #[test]
2344    #[expect(
2345        clippy::similar_names,
2346        reason = "ping/pong are the clearest names for this pair"
2347    )]
2348    fn mutual_recursion_params_stay_firewalled_to_each_defs_own_body() {
2349        // `ping` and `pong` call each other with an arithmetic expression
2350        // (`n - 1`), not a bare local — so nothing about the *callee's*
2351        // declared param type can flow backward onto the *caller's* own `n`
2352        // (call-site-driven inference is forbidden by the firewall). Each
2353        // def's own param type is pinned only by its own body's comparison:
2354        // `ping` compares `n` to an int literal, `pong` to a float literal.
2355        let (hir, index, res) = build(
2356            "=== function ping(n) ===\n{n > 0:\n  ~ return pong(n - 1)\n}\n~ return n\n\
2357             === function pong(n) ===\n{n > 0.5:\n  ~ return ping(n - 1)\n}\n~ return n\n",
2358        );
2359        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2360        let ping_sig = sig_of(&result, &index, "ping");
2361        let pong_sig = sig_of(&result, &index, "pong");
2362        assert_eq!(
2363            ping_sig.params,
2364            vec![Ty::Int],
2365            "ping's own body only compares n to an int"
2366        );
2367        assert_eq!(
2368            pong_sig.params,
2369            vec![Ty::Float],
2370            "pong's own body only compares n to a float"
2371        );
2372    }
2373
2374    #[test]
2375    #[expect(
2376        clippy::similar_names,
2377        reason = "ping/pong are the clearest names for this pair"
2378    )]
2379    fn mutual_recursion_return_type_converges_by_fixpoint() {
2380        // `ping`'s return type is `unify(Float, pong's return type)`; `pong`'s
2381        // return type is exactly `ping`'s return type. Neither def has a
2382        // concrete return type on its own — round 0 sees the other's
2383        // `Unknown` placeholder — so this only converges to `Float` because
2384        // the batch is re-solved until stable (the SCC fixpoint), not in a
2385        // single pass.
2386        let (hir, index, res) = build(
2387            "=== function ping(n) ===\n{n == 0:\n  ~ return 0.0\n}\n~ return pong(n - 1)\n\
2388             === function pong(n) ===\n~ return ping(n)\n",
2389        );
2390        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2391        let ping_sig = sig_of(&result, &index, "ping");
2392        let pong_sig = sig_of(&result, &index, "pong");
2393        assert_eq!(ping_sig.return_ty, Ty::Float);
2394        assert_eq!(pong_sig.return_ty, Ty::Float);
2395    }
2396
2397    #[test]
2398    fn intrinsic_len_types_int() {
2399        let (hir, index, res) =
2400            build("=== main ===\n~ temp arr = #[1, 2, 3]\n~ temp n = len(arr)\n-> DONE\n");
2401        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2402        let main_id = index
2403            .by_name
2404            .get("main")
2405            .and_then(|ids| ids.first())
2406            .copied()
2407            .expect("main");
2408        let body = result.bodies.get(&main_id).expect("main body");
2409        assert_eq!(body.locals.get("arr"), Some(&Ty::Array(Box::new(Ty::Int))));
2410        assert_eq!(body.locals.get("n"), Some(&Ty::Int));
2411    }
2412
2413    #[test]
2414    fn determinism_same_input_same_output() {
2415        let src = "=== function fib(n) ===\n{n < 2.0:\n  ~ return n\n}\n~ return fib(n - 1) + fib(n - 2)\n";
2416        let (hir_a, index_a, res_a) = build(src);
2417        let (hir_b, index_b, res_b) = build(src);
2418        let a = infer_project(
2419            &[(FileId(0), &hir_a)],
2420            &index_a,
2421            &res_a,
2422            None,
2423            &BTreeMap::new(),
2424        );
2425        let b = infer_project(
2426            &[(FileId(0), &hir_b)],
2427            &index_b,
2428            &res_b,
2429            None,
2430            &BTreeMap::new(),
2431        );
2432        assert_eq!(a, b, "same input must infer identical types every run");
2433    }
2434
2435    // ─── Conflicted lattice point (#627) ───────────────────────────────
2436
2437    #[test]
2438    fn genuinely_disjoint_uses_infer_param_as_conflicted() {
2439        // `hp` is compared against an int literal and a string literal —
2440        // a genuine, irreconcilable conflict. Pre-#627 this degraded to
2441        // `Unknown`, indistinguishable from "never observed".
2442        let (hir, index, res) = build(
2443            "=== conflict_case(hp) ===\n{hp > 5:\n  ok\n}\n{hp == \"no\":\n  no\n}\n-> DONE\n",
2444        );
2445        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2446        let sig = sig_of(&result, &index, "conflict_case");
2447        assert_eq!(sig.params, vec![Ty::Conflicted]);
2448    }
2449
2450    #[test]
2451    fn conflict_detection_is_order_independent_across_real_source() {
2452        // The exact bug #627 exists to close: `unify(Int, String)` used to
2453        // degrade to `Unknown`, and `observe` short-circuits on an
2454        // `Unknown` candidate (a legitimate optimization for the true
2455        // identity element) — so which concrete type "won" depended on
2456        // which comparison the walk reached *last*, silently masking the
2457        // conflict as a normal concrete type instead of surfacing it. Both
2458        // source orderings below must now infer the same `Conflicted`
2459        // param, proving detection no longer depends on declaration order.
2460        let forward =
2461            "=== conflict_fwd(hp) ===\n{hp > 5:\n  ok\n}\n{hp == \"no\":\n  no\n}\n-> DONE\n";
2462        let reversed =
2463            "=== conflict_rev(hp) ===\n{hp == \"no\":\n  no\n}\n{hp > 5:\n  ok\n}\n-> DONE\n";
2464
2465        let (hir_f, index_f, res_f) = build(forward);
2466        let result_f = infer_project(
2467            &[(FileId(0), &hir_f)],
2468            &index_f,
2469            &res_f,
2470            None,
2471            &BTreeMap::new(),
2472        );
2473        let sig_f = sig_of(&result_f, &index_f, "conflict_fwd");
2474
2475        let (hir_r, index_r, res_r) = build(reversed);
2476        let result_r = infer_project(
2477            &[(FileId(0), &hir_r)],
2478            &index_r,
2479            &res_r,
2480            None,
2481            &BTreeMap::new(),
2482        );
2483        let sig_r = sig_of(&result_r, &index_r, "conflict_rev");
2484
2485        assert_eq!(sig_f.params, vec![Ty::Conflicted], "int-then-string order");
2486        assert_eq!(sig_r.params, vec![Ty::Conflicted], "string-then-int order");
2487        assert_eq!(
2488            sig_f.params, sig_r.params,
2489            "conflict detection must not depend on observation order"
2490        );
2491    }
2492
2493    #[test]
2494    #[expect(
2495        clippy::similar_names,
2496        reason = "ping/pong are the clearest names for this pair"
2497    )]
2498    fn conflicted_absorbs_through_the_scc_fixpoint() {
2499        // `ping`'s own base case returns a string; `pong`'s own base case
2500        // returns an int; each recursive case returns whatever the other
2501        // member currently resolves to. Neither member's own body is
2502        // internally conflicted (each sees only one concrete literal type
2503        // directly), but the two base cases can never agree once threaded
2504        // through the SCC's shared fixpoint — join stays monotone, so once
2505        // either member's estimate becomes `Conflicted` mid-fixpoint it
2506        // must propagate to the other and never get diluted back to
2507        // `Unknown` in a later round (the #627 ruling's "SCC fixpoint
2508        // convergence is unaffected" clause, proven end to end here rather
2509        // than just at the `unify` unit level).
2510        let (hir, index, res) = build(
2511            "=== function ping(n) ===\n{n == 0:\n  ~ return \"done\"\n}\n~ return pong(n - 1)\n\
2512             === function pong(n) ===\n{n == 0:\n  ~ return 1\n}\n~ return ping(n - 1)\n",
2513        );
2514        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2515        let ping_sig = sig_of(&result, &index, "ping");
2516        let pong_sig = sig_of(&result, &index, "pong");
2517        assert_eq!(ping_sig.return_ty, Ty::Conflicted);
2518        assert_eq!(pong_sig.return_ty, Ty::Conflicted);
2519    }
2520
2521    // ─── Issue #1680 step 3: the effect row riding `Ty::Fn` ────────────
2522    //     (`docs/effects-spec.md` §5/§6.1c)
2523
2524    /// §5: "a cell accumulates the join of every fn value assigned into it".
2525    /// Two `#fn` literals written to one slot leave **both** targets on the
2526    /// slot's type — the join is set union, not last-write-wins.
2527    #[test]
2528    fn a_slot_written_from_two_creation_sites_carries_both_targets() {
2529        let (hir, index, res) = build(
2530            "=== function bump(n: int): int ===\n~ return n + 1\n\
2531             === function twice(n: int): int ===\n~ return n * 2\n\
2532             === main ===\n~ temp f = #fn(bump)\n~ f = #fn(twice)\n-> DONE\n",
2533        );
2534        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2535        let id_of = |name: &str| {
2536            index
2537                .by_name
2538                .get(name)
2539                .and_then(|ids| ids.first())
2540                .copied()
2541                .unwrap_or_else(|| unreachable!("no symbol named {name}"))
2542        };
2543        let body = result.bodies.get(&id_of("main")).expect("main body");
2544        let f = body.locals.get("f").expect("f");
2545        let Ty::Fn(_, _, row) = f else {
2546            unreachable!("expected a fn type, got {f:?}")
2547        };
2548        assert_eq!(
2549            row.targets(),
2550            Some(&BTreeSet::from([id_of("bump"), id_of("twice")]))
2551        );
2552    }
2553
2554    /// The top element absorbs (§3): one write whose source the type layer
2555    /// cannot name — here a call's return, whose declared `fn(int): int`
2556    /// names no creation target — poisons the slot's row for good, in
2557    /// either write order. This is the type-layer twin of §6.1a's "a single
2558    /// untraced write poisons the name".
2559    #[test]
2560    fn one_unnameable_creation_site_poisons_the_row() {
2561        for order in [
2562            "~ temp f = #fn(bump)\n~ f = pick(#fn(bump))\n",
2563            "~ temp f = pick(#fn(bump))\n~ f = #fn(bump)\n",
2564        ] {
2565            let (hir, index, res) = build(&format!(
2566                "=== function bump(n: int): int ===\n~ return n + 1\n\
2567                 === function pick(cb: fn(int): int): fn(int): int ===\n~ return cb\n\
2568                 === main ===\n{order}-> DONE\n"
2569            ));
2570            let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2571            let main_id = index
2572                .by_name
2573                .get("main")
2574                .and_then(|ids| ids.first())
2575                .copied()
2576                .expect("main");
2577            let body = result.bodies.get(&main_id).expect("main body");
2578            let f = body.locals.get("f").expect("f");
2579            let Ty::Fn(_, _, row) = f else {
2580                unreachable!("expected a fn type for {order:?}, got {f:?}")
2581            };
2582            assert!(row.is_unknown(), "order {order:?} must poison the row");
2583        }
2584    }
2585
2586    /// The second minter: a global cell whose initializer is a `#fn`
2587    /// literal gets its `Ty::Fn` from `signature::declared_fn_type`, so its
2588    /// row must name the target too — declaration-derived, resolved by name
2589    /// lookup, never from an inferred row (§6.1a). This is the shape §6
2590    /// mechanism 3 (the heap) will read once effects-spec §6.1c's stratum
2591    /// question is answered.
2592    #[test]
2593    fn a_global_fn_cell_carries_its_declared_creation_target() {
2594        let (hir, index, res) = build(
2595            "=== function heal(ref hp: int, amount: int): int ===\n~ hp = hp + amount\n~ return hp\n\
2596             VAR player_hp = 10\n\
2597             VAR healer = #fn(heal, player_hp)\n\
2598             === main ===\n-> DONE\n",
2599        );
2600        let _ = &res;
2601        let files = [(FileId(0), &hir)];
2602        let id_of = |name: &str| {
2603            index
2604                .by_name
2605                .get(name)
2606                .and_then(|ids| ids.first())
2607                .copied()
2608                .unwrap_or_else(|| unreachable!("no symbol named {name}"))
2609        };
2610        let sig = crate::signature::signature(id_of("healer"), &index, &files, None)
2611            .expect("healer signature");
2612        let ty = sig.value_ty.clone().expect("healer value_ty");
2613        let Ty::Fn(params, _, row) = &ty else {
2614            unreachable!("expected a fn type, got {ty:?}")
2615        };
2616        assert_eq!(params.len(), 1, "the `ref hp` prefix is bound away");
2617        assert_eq!(row.targets(), Some(&BTreeSet::from([id_of("heal")])));
2618    }
2619
2620    /// `bind` carries the row through unchanged: partial application never
2621    /// changes *which* def eventually runs (§6.1a).
2622    #[test]
2623    fn bind_preserves_the_creation_target_row() {
2624        let (hir, index, res) = build(
2625            "=== function add(a: int, b: int): int ===\n~ return a + b\n\
2626             === main ===\n~ temp f = #fn(add)\n~ temp g = bind(f, 1)\n-> DONE\n",
2627        );
2628        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2629        let id_of = |name: &str| {
2630            index
2631                .by_name
2632                .get(name)
2633                .and_then(|ids| ids.first())
2634                .copied()
2635                .unwrap_or_else(|| unreachable!("no symbol named {name}"))
2636        };
2637        let body = result.bodies.get(&id_of("main")).expect("main body");
2638        let g = body.locals.get("g").expect("g");
2639        let Ty::Fn(params, _, row) = g else {
2640            unreachable!("expected a fn type, got {g:?}")
2641        };
2642        assert_eq!(params.len(), 1, "one param remains after binding one");
2643        assert_eq!(row.targets(), Some(&BTreeSet::from([id_of("add")])));
2644    }
2645
2646    // ─── T1c: #fn typing + the annotation-firewall overlay ─────────────
2647
2648    /// The spec's own worked example (docs/t1c-spec.md §2/§4): with the
2649    /// target fully annotated, `#fn(heal, player_hp)` consumes the bound
2650    /// prefix and types as `fn(int): int`.
2651    #[test]
2652    fn fn_literal_consumes_the_bound_prefix_of_the_targets_signature() {
2653        let (hir, index, res) = build(
2654            "=== function heal(ref hp: int, amount: int): int ===\n~ hp = hp + amount\n~ return hp\n\
2655             VAR player_hp = 10\n\
2656             === main ===\n~ temp heal_player = #fn(heal, player_hp)\n-> DONE\n",
2657        );
2658        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2659        let main_id = index
2660            .by_name
2661            .get("main")
2662            .and_then(|ids| ids.first())
2663            .copied()
2664            .expect("main");
2665        let body = result.bodies.get(&main_id).expect("main body");
2666        let heal_id = index
2667            .by_name
2668            .get("heal")
2669            .and_then(|ids| ids.first())
2670            .copied()
2671            .expect("heal");
2672        let cb = body.locals.get("heal_player").expect("heal_player");
2673        assert_eq!(
2674            crate::infer::erase_fn_rows(cb),
2675            Ty::Fn(vec![Ty::Int], Box::new(Ty::Int), FnRow::unknown())
2676        );
2677        // Issue #1680 step 3: the `#fn` literal is the creation site, so
2678        // the slot's type carries `heal` as its effect row.
2679        let Ty::Fn(_, _, row) = cb else {
2680            unreachable!("expected a fn type, got {cb:?}")
2681        };
2682        assert_eq!(row.targets(), Some(&BTreeSet::from([heal_id])));
2683    }
2684
2685    #[test]
2686    fn fn_literal_over_an_inferred_signature_needs_no_annotations() {
2687        // The target's row can come from body inference alone.
2688        let (hir, index, res) = build(
2689            "=== function double(x) ===\n~ return x * 2\n\
2690             === main ===\n~ temp f = #fn(double)\n-> DONE\n",
2691        );
2692        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2693        let main_id = index
2694            .by_name
2695            .get("main")
2696            .and_then(|ids| ids.first())
2697            .copied()
2698            .expect("main");
2699        let body = result.bodies.get(&main_id).expect("main body");
2700        let f = body.locals.get("f").expect("f");
2701        assert_eq!(
2702            crate::infer::erase_fn_rows(f),
2703            Ty::Fn(vec![Ty::Int], Box::new(Ty::Int), FnRow::unknown())
2704        );
2705    }
2706
2707    #[test]
2708    fn fn_literal_with_unresolvable_target_stays_unknown() {
2709        let (hir, index, res) = build("=== main ===\n~ temp f = #fn(nowhere)\n-> DONE\n");
2710        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2711        let main_id = index
2712            .by_name
2713            .get("main")
2714            .and_then(|ids| ids.first())
2715            .copied()
2716            .expect("main");
2717        let body = result.bodies.get(&main_id).expect("main body");
2718        assert_eq!(body.locals.get("f"), Some(&Ty::Unknown));
2719    }
2720
2721    /// T1c overlay: an annotated param the body never constrains surfaces
2722    /// its annotation type in the inferred signature (the firewall applied
2723    /// to the signature — a `#fn` row built from it must be concrete).
2724    #[test]
2725    fn annotated_but_unconstrained_param_overlays_to_the_annotation_type() {
2726        let (hir, index, res) = build("=== noop(x: int) ===\nHello.\n-> DONE\n");
2727        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2728        let sig = sig_of(&result, &index, "noop");
2729        assert_eq!(sig.params, vec![Ty::Int]);
2730    }
2731
2732    /// Overlay is Unknown-only: a body use that disagrees with the
2733    /// annotation keeps its own derivation (E063's two-independent-
2734    /// derivations comparison, and the #627 Conflicted lattice, untouched).
2735    #[test]
2736    fn overlay_never_replaces_a_concrete_body_derivation() {
2737        let (hir, index, res) = build("=== heal(hp: string) ===\n{hp > 1:\n  ok\n}\n-> DONE\n");
2738        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2739        let sig = sig_of(&result, &index, "heal");
2740        assert_eq!(sig.params, vec![Ty::Int], "body derivation wins");
2741    }
2742
2743    // ─── Issue #1168: Option-returning functions escape as `Option[Unknown]` ─
2744
2745    /// The issue's tightest repro: `some(x)` where `x` is an annotated
2746    /// param used *only* as `some`'s argument — no comparison/arithmetic
2747    /// anywhere else in the body ever gives `x` evidence the old code path
2748    /// could pick up. `some`'s arg type is a pure read (never joined
2749    /// against a second operand), so it should still see `x`'s own
2750    /// declared type, settling the return as `Option[int]`, not
2751    /// `Option[Unknown]`.
2752    #[test]
2753    fn some_of_an_unevidenced_annotated_param_infers_option_of_its_annotation() {
2754        let (hir, index, res) = build("=== function f(x: int) ===\n~ return some(x)\n");
2755        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2756        let sig = sig_of(&result, &index, "f");
2757        assert_eq!(sig.return_ty, Ty::Option(Box::new(Ty::Int)));
2758    }
2759
2760    /// `get(m, k)` where `m` is an annotated `Map<...>` param, likewise
2761    /// never evidenced elsewhere — the confirmation comment's second
2762    /// repro ("`get(<any map>)` … infer `Option[Unknown]`").
2763    #[test]
2764    fn get_of_an_unevidenced_annotated_map_param_infers_option_of_the_value_type() {
2765        let (hir, index, res) =
2766            build("=== function f(m: Map<string, int>, k: string) ===\n~ return get(m, k)\n");
2767        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2768        let sig = sig_of(&result, &index, "f");
2769        assert_eq!(sig.return_ty, Ty::Option(Box::new(Ty::Int)));
2770    }
2771
2772    /// `iteration.md`'s `first_over` fence: a `for` loop over an annotated
2773    /// `Array<int>` param used nowhere else, `return some(<the loop var>)`
2774    /// on one path and `return none` on the other. Regression for the
2775    /// iterable-position half of #1168 (the loop var itself escaped too,
2776    /// since its type comes from the iterable's element type).
2777    #[test]
2778    fn some_of_a_for_loop_var_over_an_unevidenced_annotated_array_param() {
2779        let (hir, index, res) = build(
2780            "=== function first_over(tab: Array<int>, floor: int) ===\n\
2781             ~ {\n    for coins in tab {\n        if coins > floor {\n            return some(coins)\n        }\n    }\n}\n\
2782             ~ return none\n",
2783        );
2784        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2785        let sig = sig_of(&result, &index, "first_over");
2786        assert_eq!(sig.return_ty, Ty::Option(Box::new(Ty::Int)));
2787    }
2788
2789    /// `infer_infix`'s comparison/arithmetic arms must NOT get the new
2790    /// read-site annotation fallback — this is the same fixture as
2791    /// `overlay_never_replaces_a_concrete_body_derivation` above, repeated
2792    /// here to pin it as the #1168 fix's own regression guard: `hp` is
2793    /// annotated `string` but the body's only use compares it against an
2794    /// int literal, so body evidence (`int`) must still win outright, not
2795    /// `unify(string, int) = Conflicted`.
2796    #[test]
2797    fn comparison_evidence_still_overrides_the_annotation_after_the_1168_fix() {
2798        let (hir, index, res) = build("=== heal(hp: string) ===\n{hp > 1:\n  ok\n}\n-> DONE\n");
2799        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2800        let sig = sig_of(&result, &index, "heal");
2801        assert_eq!(sig.params, vec![Ty::Int]);
2802    }
2803
2804    /// Review correction (w65, changeset wording): only an ANNOTATED param
2805    /// or an ASCRIBED temp reaches `self.annotated` (`infer_def_body`'s
2806    /// `annotated` map + `register_ascription`) — an unascribed temp
2807    /// merely *copying* an annotated param's value does not inherit that
2808    /// annotation transitively. `v` here has no `: T` ascription of its
2809    /// own, so `some(v)` still infers `Option[Unknown]`, pinning the
2810    /// boundary the `.changeset/issue-1168-option-return-inference.md`
2811    /// wording now names explicitly ("annotated param / ascribed temp",
2812    /// not any "param/temp passed straight through").
2813    #[test]
2814    fn unascribed_temp_copy_of_an_annotated_param_does_not_inherit_the_annotation() {
2815        let (hir, index, res) =
2816            build("=== function f(x: int) ===\n~ temp v = x\n~ return some(v)\n");
2817        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2818        let sig = sig_of(&result, &index, "f");
2819        assert_eq!(sig.return_ty, Ty::Option(Box::new(Ty::Unknown)));
2820    }
2821
2822    /// Review correction (w65): `contains`'s `self.observe(needle, elem)`
2823    /// call derives `elem` from `arg_tys[0]` (the container's shape) — if
2824    /// that shape were read from `tab`'s own annotation-fallback type
2825    /// (`read_tys`) instead of its evidence-only type (`arg_tys`), `tab`'s
2826    /// `Array<int>` annotation would become body *evidence* for `needle`
2827    /// (the sibling arg), silently discarding `needle`'s own `string`
2828    /// annotation. `tab` has no other evidence anywhere in the body, so
2829    /// this pins that `contains`'s observe call never reads the
2830    /// annotation-shadowed slice: `needle` must still export its own
2831    /// declared `string`, not `tab`'s element type `int`.
2832    #[test]
2833    fn intrinsic_sibling_arg_never_seeds_from_a_containers_own_annotation() {
2834        let (hir, index, res) = build(
2835            "=== function f(tab: Array<int>, needle: string) ===\n\
2836             ~ return contains(tab, needle)\n",
2837        );
2838        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2839        let sig = sig_of(&result, &index, "f");
2840        assert_eq!(sig.params, vec![Ty::Array(Box::new(Ty::Int)), Ty::String]);
2841    }
2842
2843    #[test]
2844    fn return_annotation_overlays_an_unconstrained_return() {
2845        // `return hp` types Unknown from the body alone (nothing pins hp
2846        // before the return); the `): int` annotation overlays it.
2847        let (hir, index, res) = build("=== function passthru(hp): int ===\n~ return hp\n");
2848        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2849        let sig = sig_of(&result, &index, "passthru");
2850        assert_eq!(sig.return_ty, Ty::Int);
2851    }
2852
2853    /// Issue #1912: the *param*-annotation counterpart of the test above —
2854    /// `~ return hp` with `hp: int` annotated and no return annotation at
2855    /// all now exports `int` rather than `Unknown`, because
2856    /// `infer_return` runs the returned value through `or_own_annotation`
2857    /// (a pure read, no counter-evidence). The ink spelling is checked here
2858    /// alongside the native one in `strict::tests` because the gap was in
2859    /// `infer::body`, shared by both frontends.
2860    #[test]
2861    fn returning_an_annotated_param_exports_the_params_type() {
2862        let (hir, index, res) = build("=== function passthru(hp: int) ===\n~ return hp\n");
2863        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2864        let sig = sig_of(&result, &index, "passthru");
2865        assert_eq!(sig.return_ty, Ty::Int);
2866    }
2867
2868    /// The boundary that stays put (issue #1912 must not widen into #1168's
2869    /// w65 correction): `or_own_annotation` overlays an `Unknown` only, so
2870    /// a *concrete* body derivation still wins outright and
2871    /// `annotations::mismatches` keeps two independent derivations to
2872    /// compare. `hp` is used as a `string` here, so the return type is
2873    /// `string` — the annotation does not launder it back to `int`.
2874    #[test]
2875    fn a_concrete_body_derivation_still_beats_the_returned_params_annotation() {
2876        let (hir, index, res) = build("=== function passthru(hp: int) ===\n~ return hp + \"x\"\n");
2877        let result = infer_project(&[(FileId(0), &hir)], &index, &res, None, &BTreeMap::new());
2878        let sig = sig_of(&result, &index, "passthru");
2879        assert_eq!(sig.params, vec![Ty::String]);
2880        assert_eq!(sig.return_ty, Ty::String);
2881    }
2882
2883    // ─── Per-def/per-SCC decomposition (FG-2, issue #631) ─────────────
2884
2885    #[test]
2886    fn call_edges_matches_the_calls_infer_project_discovers() {
2887        let (hir, index, res) = build(
2888            "=== main ===\n~ temp v = 1\n~ use_it(v)\n-> DONE\n=== use_it(n) ===\n{n > 2.5:\n  big\n}\n-> DONE\n",
2889        );
2890        let files = [(FileId(0), &hir)];
2891        let main_id = index
2892            .by_name
2893            .get("main")
2894            .and_then(|ids| ids.first())
2895            .copied()
2896            .expect("main");
2897        let use_it_id = index
2898            .by_name
2899            .get("use_it")
2900            .and_then(|ids| ids.first())
2901            .copied()
2902            .expect("use_it");
2903
2904        let inferable = inferable_defs_from_index(&index);
2905        let edges = call_edges(main_id, &files, &index, &res, &inferable, None);
2906        assert_eq!(
2907            edges,
2908            BTreeSet::from([use_it_id]),
2909            "main's only call edge is to use_it"
2910        );
2911        let leaf_edges = call_edges(use_it_id, &files, &index, &res, &inferable, None);
2912        assert!(leaf_edges.is_empty(), "use_it calls nothing");
2913    }
2914
2915    #[test]
2916    fn call_edges_is_empty_for_an_unknown_def() {
2917        let (hir, index, res) = build("=== main ===\nHello.\n-> DONE\n");
2918        let files = [(FileId(0), &hir)];
2919        let bogus = DefinitionId::new(brink_format::DefinitionTag::Address, 0xDEAD_BEEF);
2920        let inferable = inferable_defs_from_index(&index);
2921        assert!(call_edges(bogus, &files, &index, &res, &inferable, None).is_empty());
2922    }
2923
2924    // ─── Lazy per-reference globals (FG-2.1, issue #638) ───────────────
2925
2926    #[test]
2927    fn inferable_defs_from_index_matches_hir_derived_set() {
2928        // Same three fixtures the FG-2/#626 tests already carry (plain
2929        // mutual call, floating stitch, nested stitch) — the index-only
2930        // projection must agree with the HIR-walking one on every shape
2931        // `collect_defs` handles, not just the easy case.
2932        let fixtures = [
2933            "=== main ===\n~ temp v = 1\n~ use_it(v)\n-> DONE\n=== use_it(n) ===\n{n > 2.5:\n  big\n}\n-> DONE\n",
2934            "= heal(hp)\n~ temp x = hp + 1\n-> DONE\n",
2935            "= intro(hp)\n~ temp x = hp + 1\n-> DONE\n\
2936             === knot_a(gold) ===\n{gold > 1.5:\n  ok\n}\n-> stitch_a ->\n\
2937             = stitch_a(silver)\n~ temp y = silver + 1\n-> DONE\n",
2938        ];
2939        for src in fixtures {
2940            let (hir, index, _res) = build(src);
2941            let files = [(FileId(0), &hir)];
2942            assert_eq!(
2943                inferable_defs_from_index(&index),
2944                inferable_defs(&files, &index),
2945                "index-sourced and HIR-walking inferable sets diverged for: {src}"
2946            );
2947        }
2948    }
2949
2950    #[test]
2951    fn referenced_globals_finds_every_var_and_const_read_in_a_body() {
2952        let (hir, index, res) = build(
2953            "VAR gold = 10\nCONST max_gold = 100\n\
2954             === spend(cost) ===\n~ gold = gold - cost\n{gold > max_gold:\n  rich\n}\n-> DONE\n",
2955        );
2956        let files = [(FileId(0), &hir)];
2957        let spend_id = index
2958            .by_name
2959            .get("spend")
2960            .and_then(|ids| ids.first())
2961            .copied()
2962            .expect("spend");
2963        let gold_id = index
2964            .by_name
2965            .get("gold")
2966            .and_then(|ids| ids.first())
2967            .copied()
2968            .expect("gold");
2969        let max_gold_id = index
2970            .by_name
2971            .get("max_gold")
2972            .and_then(|ids| ids.first())
2973            .copied()
2974            .expect("max_gold");
2975
2976        let global_refs = referenced_globals(spend_id, &files, &index, &res, None);
2977        assert_eq!(
2978            global_refs,
2979            BTreeSet::from([gold_id, max_gold_id]),
2980            "spend's body reads both gold and max_gold"
2981        );
2982    }
2983
2984    #[test]
2985    fn referenced_globals_is_empty_when_a_body_reads_no_globals() {
2986        let (hir, index, res) = build("=== main ===\n~ temp v = 1\n-> DONE\n");
2987        let files = [(FileId(0), &hir)];
2988        let main_id = index
2989            .by_name
2990            .get("main")
2991            .and_then(|ids| ids.first())
2992            .copied()
2993            .expect("main");
2994        assert!(referenced_globals(main_id, &files, &index, &res, None).is_empty());
2995    }
2996
2997    #[test]
2998    fn inferable_defs_matches_every_knot_and_stitch() {
2999        let (hir, index, res) = build(
3000            "=== main ===\n~ temp v = 1\n~ use_it(v)\n-> DONE\n=== use_it(n) ===\n{n > 2.5:\n  big\n}\n-> DONE\n",
3001        );
3002        let files = [(FileId(0), &hir)];
3003        let _ = &res;
3004        let defs = inferable_defs(&files, &index);
3005        let main_id = index
3006            .by_name
3007            .get("main")
3008            .and_then(|ids| ids.first())
3009            .copied()
3010            .expect("main");
3011        let use_it_id = index
3012            .by_name
3013            .get("use_it")
3014            .and_then(|ids| ids.first())
3015            .copied()
3016            .expect("use_it");
3017        assert_eq!(defs, BTreeSet::from([main_id, use_it_id]));
3018    }
3019
3020    /// The decomposition equivalence gate the design doc's §9 FG-2 bullet
3021    /// asks for: composing `call_edges` -> `scc_graph` -> `solve_scc` per
3022    /// component, in dependency order, must equal a single `infer_project`
3023    /// call over the exact same inputs. Uses the mutual-recursion fixture
3024    /// (a real multi-round SCC fixpoint, not just a linear chain) so the
3025    /// composed path actually exercises cross-SCC signature threading.
3026    #[test]
3027    fn composed_per_scc_solve_equals_monolithic_infer_project() {
3028        let src = "=== function ping(n) ===\n{n == 0:\n  ~ return 0.0\n}\n~ return pong(n - 1)\n\
3029                   === function pong(n) ===\n~ return ping(n)\n\
3030                   === caller ===\n~ temp x = ping(3)\n-> DONE\n";
3031        let (hir, index, res) = build(src);
3032        let files = [(FileId(0), &hir)];
3033
3034        let monolithic = infer_project(&files, &index, &res, None, &BTreeMap::new());
3035
3036        // Compose: call_edges per def -> merged CallGraph -> scc_graph ->
3037        // solve_scc per component, threading known_sigs in dependency order
3038        // exactly like `solve_batches` does internally. Every per-def input
3039        // (defs, globals, inferable) is built the same narrowed way
3040        // `brink-db`'s query wiring builds it (FG-2.1, issue #638), not by
3041        // handing the whole-project HIR straight to `collect_defs`/
3042        // `collect_globals` the way the pre-#638 version of this test did.
3043        let defs = inferable_defs_from_index(&index);
3044        let mut graph = CallGraph::new();
3045        for &def in &defs {
3046            graph.add_node(def);
3047            for callee in call_edges(def, &files, &index, &res, &defs, None) {
3048                graph.add_edge(def, callee);
3049            }
3050        }
3051        let sg = scc_graph(&graph);
3052
3053        let mut known_sigs: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
3054        let mut signatures: BTreeMap<DefinitionId, InferredSig> = BTreeMap::new();
3055        let mut bodies: BTreeMap<DefinitionId, BodyTypes> = BTreeMap::new();
3056        for batch in &sg.order {
3057            // Per-def HIR projection (Ruling 2b): only this batch's own
3058            // members' bodies, never the whole project's.
3059            let owned: Vec<(DefinitionId, Vec<Param>, Option<TypeExpr>, Block)> = batch
3060                .iter()
3061                .filter_map(|&id| def_body(id, &files, &index).map(|(p, ra, b)| (id, p, ra, b)))
3062                .collect();
3063            let batch_defs: Vec<Def<'_>> = owned
3064                .iter()
3065                .map(|(id, params, return_annotation, body)| Def {
3066                    id: *id,
3067                    file: FileId(0),
3068                    params,
3069                    body,
3070                    return_annotation: return_annotation.as_ref(),
3071                    native: false,
3072                })
3073                .collect();
3074
3075            // Pre-scan + narrow map (Ruling 1): union of every member's
3076            // referenced_globals, resolved through signature() — never
3077            // collect_globals's whole-project scan.
3078            let mut global_ids: BTreeSet<DefinitionId> = BTreeSet::new();
3079            for &id in batch {
3080                global_ids.extend(referenced_globals(id, &files, &index, &res, None));
3081            }
3082            let mut globals: BTreeMap<DefinitionId, Ty> = BTreeMap::new();
3083            for gid in global_ids {
3084                if let Some(sig) = crate::signature::signature(gid, &index, &files, None)
3085                    && let Some(vt) = sig.value_type.clone()
3086                {
3087                    globals.insert(gid, Ty::from(vt));
3088                }
3089            }
3090
3091            let (sigs, bods) = solve_scc(
3092                batch,
3093                &batch_defs,
3094                &index,
3095                &res,
3096                &globals,
3097                &defs,
3098                known_sigs.clone(),
3099                None,
3100                &BTreeMap::new(),
3101            );
3102            known_sigs.extend(sigs.iter().map(|(k, v)| (*k, v.clone())));
3103            signatures.extend(sigs);
3104            bodies.extend(bods);
3105        }
3106        let composed = InferenceResult { signatures, bodies };
3107
3108        assert_eq!(
3109            composed, monolithic,
3110            "per-SCC composed inference must equal a single infer_project call"
3111        );
3112    }
3113
3114    // ─── T2-1 effect rows (docs/effects-spec.md §2/§4, issue #860) ───────
3115
3116    fn id_of(index: &SymbolIndex, name: &str) -> DefinitionId {
3117        index
3118            .by_name
3119            .get(name)
3120            .and_then(|ids| ids.first())
3121            .copied()
3122            .expect("no def with this name")
3123    }
3124
3125    /// THE conservative-total soundness gate (docs/effects-spec.md §3, issue
3126    /// #860): for every def, the inferred row must **cover** (⊒) its own body
3127    /// atoms *and* every direct callee's finalized row — the no-under-report
3128    /// invariant. Exercised over a mutually-recursive fixture (`ping <-> pong`)
3129    /// so the check runs against a real multi-round SCC fixpoint, plus a
3130    /// higher-order value-call (`apply`) so the pessimal floor is in the mix,
3131    /// plus a caller (`hocaller`) that **instantiates** `apply`'s §6.1 row
3132    /// variable (issue #1680, Fork C) so check (2) below has to reason about a
3133    /// holed callee, not only ground ones.
3134    #[test]
3135    fn conservative_total_no_under_report_over_mutual_recursion() {
3136        let src = "VAR gold = 0\nVAR hp = 10\nEXTERNAL play_sfx(x)\n\
3137                   === function ping(n) ===\n~ gold = gold + 1\n\
3138                   {n == 0:\n  ~ return 0\n}\n~ play_sfx(n)\n~ return pong(n - 1)\n\
3139                   === function pong(n) ===\n~ hp = hp - 1\n~ return ping(n)\n\
3140                   === function apply(cb) ===\n~ return cb(1)\n\
3141                   === caller ===\n~ temp x = ping(3)\n-> DONE\n\
3142                   === hocaller ===\n~ temp y = apply(#fn(pong))\n-> DONE\n";
3143        let (hir, index, res) = build(src);
3144        let files = [(FileId(0), &hir)];
3145        let inferable = inferable_defs(&files, &index);
3146
3147        let rows = effects_project(&files, &index, &res, None);
3148
3149        for &def in &inferable {
3150            let row = rows.get(&def).cloned().unwrap_or_default();
3151            let atoms = def_effect_atoms(def, &files, &index, &res, &inferable, None);
3152
3153            // (1) covers its own body atoms.
3154            assert!(
3155                row.covers(&atoms.base_row()),
3156                "def {def:?} row must cover its own body atoms"
3157            );
3158
3159            // (2) covers every direct callee's finalized row —
3160            // instantiation-aware for a holed callee (issue #1680 review
3161            // finding). A callee row still carrying a §6.1 hole is itself
3162            // pessimal by construction (`is_pessimal`), so it is never a
3163            // meaningful target for `covers`: no non-opaque caller could ever
3164            // cover it, which would make this gate reject exactly the rows
3165            // #1680 ships as sound. What the caller actually owes is the
3166            // callee's *instantiated* row — its ground atoms joined with
3167            // whatever this call site traced into each hole — the same
3168            // computation `solve_scc_effects` folds into `row` itself via
3169            // `instantiate_hole`.
3170            for callee in &atoms.direct_calls {
3171                let callee_row = rows.get(callee).cloned().unwrap_or_default();
3172                let mut effective = EffectRow {
3173                    holes: BTreeSet::new(),
3174                    ..callee_row.clone()
3175                };
3176                for &hole in &callee_row.holes {
3177                    effects::instantiate_hole(
3178                        &mut effective,
3179                        atoms.call_fn_args.get(&(*callee, hole)),
3180                        &rows,
3181                        &BTreeMap::new(),
3182                    );
3183                }
3184                assert!(
3185                    row.covers(&effective),
3186                    "def {def:?} row must cover callee {callee:?}'s instantiated row"
3187                );
3188            }
3189        }
3190    }
3191
3192    #[test]
3193    fn effect_row_collects_read_write_and_external_call_atoms() {
3194        let src = "VAR gold = 0\nVAR hp = 10\nEXTERNAL play_sfx(x)\n\
3195                   === function spend(cost) ===\n~ gold = gold - cost\n\
3196                   ~ temp before = hp\n~ play_sfx(cost)\n~ return gold\n";
3197        let (hir, index, res) = build(src);
3198        let files = [(FileId(0), &hir)];
3199        let rows = effects_project(&files, &index, &res, None);
3200
3201        let spend = id_of(&index, "spend");
3202        let gold = id_of(&index, "gold");
3203        let hp = id_of(&index, "hp");
3204        let row = &rows[&spend];
3205
3206        assert!(row.reads.contains(&gold), "reads gold ({gold:?})");
3207        assert!(row.reads.contains(&hp), "reads hp ({hp:?})");
3208        assert!(row.writes.contains(&gold), "writes gold ({gold:?})");
3209        assert!(!row.writes.contains(&hp), "never writes hp");
3210        assert!(row.calls.contains("play_sfx"), "calls the external kind");
3211        assert!(!row.opaque, "a fully-visible body is not pessimal");
3212    }
3213
3214    // ─── NS-A6 (issue #1112, docs/stdlib-spec.md §7): every draw is an
3215    // ordinary write to the RNG cell in the row ─────────────────────────
3216
3217    /// Every brink draw-verb spelling harvests a write to
3218    /// `DefinitionId::RNG_CELL` — the "draws = writes" half of the
3219    /// rng-as-cell ruling.
3220    #[test]
3221    fn rand_draw_verbs_write_the_rng_cell() {
3222        use brink_format::DefinitionId;
3223        let cases: &[(&str, &str)] = &[
3224            (
3225                "float_draw",
3226                "=== function float_draw() ===\n~ return float()\n",
3227            ),
3228            (
3229                "chance_draw",
3230                "=== function chance_draw() ===\n~ return chance(0.5)\n",
3231            ),
3232            (
3233                "pick_draw",
3234                "=== function pick_draw() ===\n~ temp a = #[1, 2, 3]\n~ return pick(a)\n",
3235            ),
3236            (
3237                "shuffled_draw",
3238                "=== function shuffled_draw() ===\n~ temp a = #[1, 2, 3]\n~ return shuffled(a)\n",
3239            ),
3240            (
3241                "shuffle_stmt",
3242                "VAR deck = 0\n=== function shuffle_stmt() ===\n~ shuffle(deck)\n~ return 0\n",
3243            ),
3244            (
3245                "seed_stmt",
3246                "=== function seed_stmt() ===\n~ seed(42)\n~ return 0\n",
3247            ),
3248        ];
3249        for (name, src) in cases {
3250            let (hir, index, res) = build(src);
3251            let files = [(FileId(0), &hir)];
3252            let rows = effects_project(&files, &index, &res, None);
3253            let def = id_of(&index, name);
3254            assert!(
3255                rows[&def].writes.contains(&DefinitionId::RNG_CELL),
3256                "`{name}`'s row must contain the RNG-cell write; got {:?}",
3257                rows[&def].writes
3258            );
3259        }
3260    }
3261
3262    /// The frozen ink spellings write the SAME cell — one RNG, two
3263    /// surfaces, one row entry (no drift).
3264    #[test]
3265    fn frozen_ink_random_spellings_write_the_same_rng_cell() {
3266        use brink_format::DefinitionId;
3267        let cases: &[(&str, &str)] = &[
3268            (
3269                "roll_ink",
3270                "=== function roll_ink() ===\n~ return RANDOM(1, 6)\n",
3271            ),
3272            (
3273                "seed_ink",
3274                "=== function seed_ink() ===\n~ SEED_RANDOM(9)\n~ return 0\n",
3275            ),
3276            (
3277                "pick_ink",
3278                "LIST moods = happy, sad\n=== function pick_ink() ===\n~ return LIST_RANDOM(moods)\n",
3279            ),
3280        ];
3281        for (name, src) in cases {
3282            let (hir, index, res) = build(src);
3283            let files = [(FileId(0), &hir)];
3284            let rows = effects_project(&files, &index, &res, None);
3285            let def = id_of(&index, name);
3286            assert!(
3287                rows[&def].writes.contains(&DefinitionId::RNG_CELL),
3288                "ink `{name}`'s row must contain the RNG-cell write; got {:?}",
3289                rows[&def].writes
3290            );
3291        }
3292    }
3293
3294    /// The unary `float(x)` conversion intrinsic stays pure — only the
3295    /// nullary draw spelling touches the cell (the F4 arity split).
3296    #[test]
3297    fn unary_float_conversion_does_not_write_the_rng_cell() {
3298        use brink_format::DefinitionId;
3299        let src = "=== function conv(x) ===\n~ return float(x)\n";
3300        let (hir, index, res) = build(src);
3301        let files = [(FileId(0), &hir)];
3302        let rows = effects_project(&files, &index, &res, None);
3303        let def = id_of(&index, "conv");
3304        assert!(
3305            !rows[&def].writes.contains(&DefinitionId::RNG_CELL),
3306            "unary float(x) is the conversion — no draw, no cell write"
3307        );
3308        // And the nullary draw is total: no fault path.
3309        let src = "=== function draw() ===\n~ return float()\n";
3310        let (hir, index, res) = build(src);
3311        let files = [(FileId(0), &hir)];
3312        let rows = effects_project(&files, &index, &res, None);
3313        let draw = id_of(&index, "draw");
3314        assert!(
3315            !rows[&draw].faults,
3316            "nullary float() has no argument and no fault path"
3317        );
3318    }
3319
3320    /// `shuffle(ref a)` records BOTH writes: the receiver cell (the #880
3321    /// mutator-call lesson) and the RNG cell.
3322    #[test]
3323    fn shuffle_writes_both_the_receiver_and_the_rng_cell() {
3324        use brink_format::DefinitionId;
3325        let src = "VAR deck = 0\n=== function riffle() ===\n~ shuffle(deck)\n~ return 0\n";
3326        let (hir, index, res) = build(src);
3327        let files = [(FileId(0), &hir)];
3328        let rows = effects_project(&files, &index, &res, None);
3329        let def = id_of(&index, "riffle");
3330        let deck = id_of(&index, "deck");
3331        assert!(rows[&def].writes.contains(&deck), "writes the receiver");
3332        assert!(
3333            rows[&def].writes.contains(&DefinitionId::RNG_CELL),
3334            "writes the RNG cell"
3335        );
3336    }
3337
3338    /// The rng write propagates transitively like any other write atom —
3339    /// a caller of a draw-bearing def carries the cell in its own row
3340    /// (this is what makes the pure-gated machinery exclude draws for
3341    /// free).
3342    #[test]
3343    fn rng_write_propagates_to_callers_through_the_fixpoint() {
3344        use brink_format::DefinitionId;
3345        let src = "=== function outer() ===\n~ return inner()\n\
3346                   === function inner() ===\n~ return chance(0.25)\n";
3347        let (hir, index, res) = build(src);
3348        let files = [(FileId(0), &hir)];
3349        let rows = effects_project(&files, &index, &res, None);
3350        for name in ["outer", "inner"] {
3351            let def = id_of(&index, name);
3352            assert!(
3353                rows[&def].writes.contains(&DefinitionId::RNG_CELL),
3354                "`{name}` must carry the transitive RNG-cell write"
3355            );
3356        }
3357    }
3358
3359    #[test]
3360    fn mutually_recursive_defs_share_the_unioned_row() {
3361        let src = "VAR gold = 0\nVAR hp = 10\n\
3362                   === function ping(n) ===\n~ gold = gold + 1\n\
3363                   {n == 0:\n  ~ return 0\n}\n~ return pong(n - 1)\n\
3364                   === function pong(n) ===\n~ hp = hp - 1\n~ return ping(n)\n";
3365        let (hir, index, res) = build(src);
3366        let files = [(FileId(0), &hir)];
3367        let rows = effects_project(&files, &index, &res, None);
3368
3369        let left = id_of(&index, "ping");
3370        let right = id_of(&index, "pong");
3371        let gold = id_of(&index, "gold");
3372        let hp = id_of(&index, "hp");
3373
3374        // Both SCC members converge on the union of both writes.
3375        for def in [left, right] {
3376            let row = &rows[&def];
3377            assert!(row.writes.contains(&gold), "{def:?} writes gold");
3378            assert!(row.writes.contains(&hp), "{def:?} writes hp");
3379        }
3380    }
3381
3382    #[test]
3383    fn a_call_through_a_function_value_is_pessimal() {
3384        // docs/effects-spec.md §4 gradual corollary: an `Unknown`-typed callee
3385        // slot (here a `cb` param called as `cb(1)`) has no row to read → the
3386        // enclosing def's row is pessimal.
3387        //
3388        // §6.1 (issue #1680) changed *how* it is pessimal, not *that* it is:
3389        // the param is now a row variable rather than the intrinsic opaque
3390        // floor, so the assertion reads `is_pessimal()` — which is what every
3391        // consumer of a row reads.
3392        let src = "=== function apply(cb) ===\n~ return cb(1)\n";
3393        let (hir, index, res) = build(src);
3394        let files = [(FileId(0), &hir)];
3395        let rows = effects_project(&files, &index, &res, None);
3396        let apply = id_of(&index, "apply");
3397        assert!(
3398            rows[&apply].is_pessimal(),
3399            "a call through a function value must be pessimal"
3400        );
3401    }
3402
3403    /// §6.1 mechanism 1 (issue #1680): a call through an unwritten, non-`ref`
3404    /// fn-typed param mints a **row variable** at that param's declaration
3405    /// index instead of the intrinsic opaque floor. Read on its own the row
3406    /// is still pessimal — that is `is_pessimal`'s whole job — but the hole
3407    /// is what lets a caller do better (see the instantiation tests below).
3408    #[test]
3409    fn a_call_through_a_fn_typed_param_mints_a_row_variable() {
3410        let src = "=== function apply(a, cb) ===\n~ return cb(a)\n";
3411        let (hir, index, res) = build(src);
3412        let files = [(FileId(0), &hir)];
3413        let rows = effects_project(&files, &index, &res, None);
3414        let apply = id_of(&index, "apply");
3415        assert_eq!(
3416            rows[&apply].holes,
3417            [1].into_iter().collect::<BTreeSet<u32>>(),
3418            "the hole is keyed by the called param's declaration index"
3419        );
3420        assert!(
3421            !rows[&apply].opaque,
3422            "the floor is the hole, not intrinsic opacity"
3423        );
3424        assert!(
3425            rows[&apply].is_pessimal(),
3426            "an uninstantiated row variable still tops the lattice"
3427        );
3428    }
3429
3430    /// §6.1's payoff: the caller passes a traceable `#fn` value, so the
3431    /// higher-order callee's row variable is **instantiated** with that
3432    /// target's real row and the caller escapes the pessimal floor entirely.
3433    #[test]
3434    fn a_caller_instantiates_the_callees_row_variable() {
3435        let src = "VAR gold = 0\n\
3436                   === function writer(n) ===\n~ gold = gold + n\n~ return gold\n\
3437                   === function apply(cb) ===\n~ return cb(1)\n\
3438                   === function main() ===\n~ return apply(#fn(writer))\n";
3439        let (hir, index, res) = build(src);
3440        let files = [(FileId(0), &hir)];
3441        let rows = effects_project(&files, &index, &res, None);
3442        let main = id_of(&index, "main");
3443        let gold = id_of(&index, "gold");
3444        assert!(
3445            !rows[&main].is_pessimal(),
3446            "a fully-traced higher-order call is not pessimal"
3447        );
3448        assert!(
3449            rows[&main].holes.is_empty(),
3450            "a discharged hole belongs to the callee's param space, never the caller's"
3451        );
3452        assert!(
3453            rows[&main].writes.contains(&gold),
3454            "the instantiated row must carry the callback's own write"
3455        );
3456    }
3457
3458    /// Two call sites, two different callbacks in the same position: the fill
3459    /// is the **join** over both (Fork A's join-over-writes rule applied to
3460    /// arguments), never a pick.
3461    #[test]
3462    fn two_call_sites_join_both_callbacks_into_the_hole() {
3463        let src = "VAR gold = 0\nVAR hp = 10\n\
3464                   === function pays(n) ===\n~ gold = gold + n\n~ return gold\n\
3465                   === function hurts(n) ===\n~ hp = hp - n\n~ return hp\n\
3466                   === function apply(cb) ===\n~ return cb(1)\n\
3467                   === function main() ===\n\
3468                   ~ temp a = apply(#fn(pays))\n~ temp b = apply(#fn(hurts))\n~ return a + b\n";
3469        let (hir, index, res) = build(src);
3470        let files = [(FileId(0), &hir)];
3471        let rows = effects_project(&files, &index, &res, None);
3472        let main = id_of(&index, "main");
3473        assert!(!rows[&main].is_pessimal());
3474        assert!(rows[&main].writes.contains(&id_of(&index, "gold")));
3475        assert!(rows[&main].writes.contains(&id_of(&index, "hp")));
3476    }
3477
3478    /// The soundness guard on the join above: the summary is keyed by
3479    /// `(callee, position)` and folded over *every* call site, so one site
3480    /// passing something untraceable poisons the position for all of them.
3481    /// Were it not, this caller's row would claim to be bounded by `pays`
3482    /// while `outside` could hold any fn value the caller was handed.
3483    #[test]
3484    fn one_untraced_call_site_poisons_the_whole_position() {
3485        let src = "VAR gold = 0\n\
3486                   === function pays(n) ===\n~ gold = gold + n\n~ return gold\n\
3487                   === function apply(cb) ===\n~ return cb(1)\n\
3488                   === function main(outside) ===\n\
3489                   ~ temp a = apply(#fn(pays))\n~ temp b = apply(outside)\n~ return a + b\n";
3490        let (hir, index, res) = build(src);
3491        let files = [(FileId(0), &hir)];
3492        let rows = effects_project(&files, &index, &res, None);
3493        let main = id_of(&index, "main");
3494        assert!(
3495            rows[&main].is_pessimal(),
3496            "an untraced argument in a holed position must keep the floor"
3497        );
3498    }
3499
3500    /// A param the body **reassigns** no longer holds what the caller passed,
3501    /// so it must not carry a row variable — the same soundness argument that
3502    /// keeps a Param out of `ValueCallOrigin::Local`.
3503    #[test]
3504    fn a_reassigned_param_carries_no_row_variable() {
3505        let src = "VAR gold = 0\n\
3506                   === function pays(n) ===\n~ gold = gold + n\n~ return gold\n\
3507                   === function apply(cb) ===\n~ cb = #fn(pays)\n~ return cb(1)\n";
3508        let (hir, index, res) = build(src);
3509        let files = [(FileId(0), &hir)];
3510        let rows = effects_project(&files, &index, &res, None);
3511        let apply = id_of(&index, "apply");
3512        assert!(
3513            rows[&apply].holes.is_empty(),
3514            "a written param is not a row variable"
3515        );
3516        assert!(
3517            rows[&apply].opaque,
3518            "it keeps the intrinsic pessimal floor instead"
3519        );
3520    }
3521
3522    /// A `ref` param aliases the caller's own storage, so what it holds at
3523    /// the call-through site is not pinned by the argument expression — it is
3524    /// excluded from row variables at construction.
3525    #[test]
3526    fn a_ref_param_carries_no_row_variable() {
3527        let src = "=== function apply(ref cb) ===\n~ return cb(1)\n";
3528        let (hir, index, res) = build(src);
3529        let files = [(FileId(0), &hir)];
3530        let rows = effects_project(&files, &index, &res, None);
3531        let apply = id_of(&index, "apply");
3532        assert!(rows[&apply].holes.is_empty(), "`ref` params are excluded");
3533        assert!(rows[&apply].opaque, "so the call keeps the intrinsic floor");
3534    }
3535
3536    /// §6.1 is shallow by ruling ("every value's row is fixed at its creation
3537    /// site"): passing one's *own* fn-typed param straight through to another
3538    /// higher-order callee would chain a hole into a hole, which is not
3539    /// attempted — the forwarding definition takes the floor.
3540    #[test]
3541    fn forwarding_a_param_into_another_hole_does_not_chain() {
3542        let src = "=== function apply(cb) ===\n~ return cb(1)\n\
3543                   === function forward(cb) ===\n~ return apply(cb)\n";
3544        let (hir, index, res) = build(src);
3545        let files = [(FileId(0), &hir)];
3546        let rows = effects_project(&files, &index, &res, None);
3547        let forward = id_of(&index, "forward");
3548        assert!(
3549            rows[&forward].is_pessimal(),
3550            "a forwarded row variable is not chained — the floor stands"
3551        );
3552    }
3553
3554    #[test]
3555    fn a_pure_body_has_an_empty_row() {
3556        let src = "=== function double(n) ===\n~ return n * 2\n";
3557        let (hir, index, res) = build(src);
3558        let files = [(FileId(0), &hir)];
3559        let rows = effects_project(&files, &index, &res, None);
3560        let double = id_of(&index, "double");
3561        assert!(
3562            rows[&double].is_empty(),
3563            "a pure arithmetic body reads/writes/calls nothing"
3564        );
3565    }
3566
3567    /// Review-finding regression (issue #860's PR): a direct call passing a
3568    /// VAR/CONST global into a `ref` parameter slot writes through that
3569    /// parameter (docs/effects-spec.md §5 "through parameters") — the callee
3570    /// mutates the *caller's* cell. The exact fixture the reviewer supplied
3571    /// (`tests/tier1/variables/variable-pointer-ref-from-knot/story.ink`):
3572    /// `inc`'s own body atoms are empty (its assignment target `x` is a
3573    /// `Param`, never a `Variable`/`Constant`), so ground truth only shows up
3574    /// at `knot`'s own call site — the `conservative_total_no_under_report`
3575    /// property test above can never catch this since it only checks
3576    /// inter-row consistency, never this kind of ground-truth completeness.
3577    #[test]
3578    fn a_direct_call_writes_through_a_ref_param_at_the_call_site() {
3579        let src = "VAR val = 5\n\
3580                   === knot ===\n~ inc(val)\n{val}\n->->\n\
3581                   === function inc(ref x) ===\n~ x = x + 1\n";
3582        let (hir, index, res) = build(src);
3583        let files = [(FileId(0), &hir)];
3584        let rows = effects_project(&files, &index, &res, None);
3585
3586        let knot = id_of(&index, "knot");
3587        let inc = id_of(&index, "inc");
3588        let val = id_of(&index, "val");
3589
3590        assert!(
3591            rows[&knot].writes.contains(&val),
3592            "knot's call `inc(val)` writes through inc's `ref x` param — the \
3593             write atom must not be dropped"
3594        );
3595        assert!(
3596            !rows[&inc].writes.contains(&val),
3597            "inc's own body never names `val` — the write is only visible at \
3598             the call site, not inc's own atoms"
3599        );
3600    }
3601
3602    // ─── T2 §8 precision rung (docs/effects-spec.md §6 item 3/§8, issue #872):
3603    // reading a concrete `EffectRow` off a stored `Ty::Fn` at an indirect/
3604    // value call site, instead of the pessimal placeholder, when the origin
3605    // is statically known ──────────────────────────────────────────────
3606
3607    /// The core narrowing case: a write-once local holding a `#fn(target)`
3608    /// literal, called with the direct `f(args)` syntax. `user`'s row must
3609    /// stop being pessimal and instead cover `bar`'s real row (the write to
3610    /// `total`) — the exact improvement over the old unconditional-opaque
3611    /// floor `a_call_through_a_function_value_is_pessimal` still pins for
3612    /// the genuinely-unknown (param) case.
3613    #[test]
3614    fn known_fn_value_call_narrows_the_row_instead_of_pessimal() {
3615        let src = "VAR total = 0\n\
3616                   === function bar() ===\n~ total = total + 1\n~ return total\n\
3617                   === function user() ===\n~ temp f = #fn(bar)\n~ return f()\n";
3618        let (hir, index, res) = build(src);
3619        let files = [(FileId(0), &hir)];
3620        let rows = effects_project(&files, &index, &res, None);
3621        let user = id_of(&index, "user");
3622        let total = id_of(&index, "total");
3623        assert!(
3624            !rows[&user].opaque,
3625            "a call through a write-once local with a known #fn origin must narrow, not stay pessimal"
3626        );
3627        assert!(
3628            rows[&user].writes.contains(&total),
3629            "the narrowed row must cover bar's real write to total"
3630        );
3631    }
3632
3633    /// Same shape, through the explicit `call(f, …)` intrinsic form —
3634    /// `check_value_call`'s other caller.
3635    #[test]
3636    fn known_fn_value_call_intrinsic_form_narrows_the_row() {
3637        let src = "VAR total = 0\n\
3638                   === function bar() ===\n~ total = total + 1\n~ return total\n\
3639                   === function user() ===\n~ temp f = #fn(bar)\n~ return call(f)\n";
3640        let (hir, index, res) = build(src);
3641        let files = [(FileId(0), &hir)];
3642        let rows = effects_project(&files, &index, &res, None);
3643        let user = id_of(&index, "user");
3644        let total = id_of(&index, "total");
3645        assert!(
3646            !rows[&user].opaque,
3647            "call(f) through a known origin must narrow"
3648        );
3649        assert!(rows[&user].writes.contains(&total));
3650    }
3651
3652    /// A `bind(…)`-wrapped fn-value ("bound fn-values", the issue's own
3653    /// phrasing) stored in a write-once local — `bind` never changes which
3654    /// def eventually runs, so the origin still traces through.
3655    #[test]
3656    fn bound_fn_value_through_a_write_once_local_narrows() {
3657        let src = "VAR total = 0\n\
3658                   === function bar(n) ===\n~ total = total + n\n~ return total\n\
3659                   === function user() ===\n~ temp f = bind(#fn(bar), 5)\n~ return call(f)\n";
3660        let (hir, index, res) = build(src);
3661        let files = [(FileId(0), &hir)];
3662        let rows = effects_project(&files, &index, &res, None);
3663        let user = id_of(&index, "user");
3664        let total = id_of(&index, "total");
3665        assert!(
3666            !rows[&user].opaque,
3667            "a bind()-wrapped known origin stored write-once must still narrow"
3668        );
3669        assert!(rows[&user].writes.contains(&total));
3670    }
3671
3672    /// A fully inline `#fn(target)` literal passed straight into `call(…)`
3673    /// with no intermediate local at all — no stored-value/write-count
3674    /// question applies, so this narrows unconditionally.
3675    #[test]
3676    fn inline_fn_literal_at_the_call_site_narrows_without_a_stored_local() {
3677        let src = "VAR total = 0\n\
3678                   === function bar() ===\n~ total = total + 1\n~ return total\n\
3679                   === function user() ===\n~ return call(#fn(bar))\n";
3680        let (hir, index, res) = build(src);
3681        let files = [(FileId(0), &hir)];
3682        let rows = effects_project(&files, &index, &res, None);
3683        let user = id_of(&index, "user");
3684        let total = id_of(&index, "total");
3685        assert!(
3686            !rows[&user].opaque,
3687            "an inline #fn literal callee must narrow"
3688        );
3689        assert!(rows[&user].writes.contains(&total));
3690    }
3691
3692    /// Fork A (`docs/decision-log.md` 2026-07-28, issue #1726) supersedes the
3693    /// pre-#1726 write-once guard here. The old rule narrowed to a *single*
3694    /// def, so a local reassigned to a second known origin had to stay
3695    /// pessimal — picking either origin would under-report whichever branch
3696    /// didn't run. Joining **both** creation targets removes the choice: the
3697    /// row covers every value the local can hold, which over-reports at worst
3698    /// and so keeps the conservative-total direction (spec §3). The two
3699    /// origins write two *different* globals here so the join is visible —
3700    /// a single shared global would pass even if only one edge were taken.
3701    #[test]
3702    fn a_local_reassigned_to_a_second_known_origin_joins_both_rows() {
3703        let src = "VAR total = 0\nVAR extra = 0\n\
3704                   === function bar() ===\n~ total = total + 1\n~ return total\n\
3705                   === function baz() ===\n~ extra = extra + 100\n~ return extra\n\
3706                   === function user(cond) ===\n~ temp f = #fn(bar)\n\
3707                   {cond:\n  ~ f = #fn(baz)\n}\n~ return f()\n";
3708        let (hir, index, res) = build(src);
3709        let files = [(FileId(0), &hir)];
3710        let rows = effects_project(&files, &index, &res, None);
3711        let user = id_of(&index, "user");
3712        let total = id_of(&index, "total");
3713        let extra = id_of(&index, "extra");
3714        assert!(
3715            !rows[&user].opaque,
3716            "every write to f traced to an in-project creation site, so the \
3717             row must collapse to a real row instead of the pessimal floor"
3718        );
3719        assert!(
3720            rows[&user].writes.contains(&total),
3721            "the join must cover bar's write to total"
3722        );
3723        assert!(
3724            rows[&user].writes.contains(&extra),
3725            "the join must cover baz's write to extra — narrowing to a single \
3726             origin would under-report the other branch"
3727        );
3728    }
3729
3730    /// The guard Fork A keeps: one write whose value did **not** trace to an
3731    /// in-project creation site poisons the whole name. Here `f` is
3732    /// reassigned from a param, so the reaching value could have been created
3733    /// anywhere — including a host callback (spec §6.2) — and the row must
3734    /// stay pessimal even though the *other* write is a perfectly good
3735    /// `#fn(bar)`.
3736    #[test]
3737    fn a_local_with_one_untraced_write_stays_pessimal() {
3738        let src = "VAR total = 0\n\
3739                   === function bar() ===\n~ total = total + 1\n~ return total\n\
3740                   === function user(cond, cb) ===\n~ temp f = #fn(bar)\n\
3741                   {cond:\n  ~ f = cb\n}\n~ return f()\n";
3742        let (hir, index, res) = build(src);
3743        let files = [(FileId(0), &hir)];
3744        let rows = effects_project(&files, &index, &res, None);
3745        let user = id_of(&index, "user");
3746        assert!(
3747            rows[&user].opaque,
3748            "a write from an untraceable source must keep the pessimal floor"
3749        );
3750    }
3751
3752    /// Review-finding regression (Fork A, issue #1726): a Temp local passed
3753    /// into a `ref` parameter slot is rebound by the *callee* to whatever the
3754    /// caller passed for that other position — `poke(f, cb)` below can leave
3755    /// `f` holding `cb`, an arbitrary caller-supplied value, exactly like the
3756    /// param-assignment case `a_local_with_one_untraced_write_stays_pessimal`
3757    /// covers. Before `record_ref_param_writes` folded this into
3758    /// `local_fn_origins` too, `f`'s summary saw only its one traced
3759    /// `#fn(bar)` write and Fork A's join-over-writes rule narrowed `user`'s
3760    /// row to `bar`'s alone — silently dropping the fact that `f` could also
3761    /// be `cb` after the `poke` call. The row must stay pessimal instead.
3762    #[test]
3763    fn a_ref_param_rebind_through_a_call_site_stays_pessimal() {
3764        let src = "VAR total = 0\n\
3765                   === function bar() ===\n~ total = total + 1\n~ return total\n\
3766                   === function poke(ref g, h) ===\n~ g = h\n\
3767                   === function user(cond, cb) ===\n~ temp f = #fn(bar)\n\
3768                   {cond:\n  ~ poke(f, cb)\n}\n~ return f()\n";
3769        let (hir, index, res) = build(src);
3770        let files = [(FileId(0), &hir)];
3771        let rows = effects_project(&files, &index, &res, None);
3772        let user = id_of(&index, "user");
3773        assert!(
3774            rows[&user].opaque,
3775            "a ref-param rebind at a call site is an untraced write to the \
3776             local — narrowing through it under-reports whatever the caller \
3777             actually passed"
3778        );
3779    }
3780
3781    // ─── Issue #1735: the fn-value aliasing channel enumeration ──────────
3782    //
3783    // Filed from the #1726/PR #1731 retro to check whether `ref` projections
3784    // and the heap are a genuine gap in `local_fn_origins` or a case
3785    // docs/effects-spec.md §5/§6.1a/§6.3 already rules coarse-but-sound. They
3786    // are the latter: §5 rules that a cell/collection's element *type*
3787    // accumulates the join of every fn value assigned into it — a
3788    // completely separate mechanism from this per-local write-set rung, and
3789    // "no separate points-to machinery exists or is planned". These two
3790    // tests pin that: a heap-sourced call never narrows, and a `ref`-param
3791    // write through a *global* root never leaks into (or out of) a Temp's
3792    // own write summary. No production change accompanies these — see
3793    // docs/effects-spec.md §6.1a's "Aliasing channel enumeration" addendum
3794    // for the ruling this pins.
3795
3796    /// The heap channel (§5/§6.3): a fn value read out of a `VAR`/`CONST`
3797    /// cell is never classified as [`ValueCallOrigin::Local`] —
3798    /// [`InferPass::local_call_origin`] only recognizes `Temp`/`Param`
3799    /// symbol kinds, so a `Variable` falls straight to `Unknown`. Calling
3800    /// through it must stay pessimal unconditionally; narrowing it would
3801    /// require the points-to machinery §5 rules out, and reading it through
3802    /// the type-row join instead is a completely different (type-level, not
3803    /// call-graph-level) mechanism from what this test checks.
3804    #[test]
3805    fn a_call_through_a_heap_stored_fn_value_stays_pessimal() {
3806        let src = "VAR cb = #fn(bar)\n\
3807                   === function bar() ===\n~ return 1\n\
3808                   === function user() ===\n~ return cb()\n";
3809        let (hir, index, res) = build(src);
3810        let files = [(FileId(0), &hir)];
3811        let rows = effects_project(&files, &index, &res, None);
3812        let user = id_of(&index, "user");
3813        assert!(
3814            rows[&user].opaque,
3815            "a call through a VAR-held fn value is the heap channel — \
3816             local_fn_origins never sees VAR/CONST writes at all, so it \
3817             must stay pessimal rather than attempt to narrow"
3818        );
3819    }
3820
3821    /// A `ref`-param write whose root is a *global* (not a Temp) — the same
3822    /// call-site mechanism `a_ref_param_rebind_through_a_call_site_stays_pessimal`
3823    /// exercises, but aimed at a differently-named `VAR` root (`npc`)
3824    /// instead of the Temp being narrowed (`f`). [`InferPass::record_fn_write`]
3825    /// only folds a write into `local_fn_origins` for a `Temp`/`Param`
3826    /// target — a `Variable` target is a documented no-op there (the heap
3827    /// case is §5's job, not this rung's). This pins the common case: `f`'s
3828    /// own single, fully traced `#fn(bar)` write is untouched by a sibling
3829    /// ref-write to `npc`, so `user`'s row narrows instead of spuriously
3830    /// falling to the pessimal floor.
3831    ///
3832    /// This does **not** pin the no-op's load-bearing case. `local_fn_origins`
3833    /// is keyed by `String` name, and `npc`/`f` are different names, so they
3834    /// can never collide in that map regardless of whether `record_fn_write`'s
3835    /// `Variable` arm is a no-op or is folded into `bump_local_write` —
3836    /// deleting the guard leaves this exact test green. A genuine collision
3837    /// needs a global root that resolves under the *same* name key as the
3838    /// traced local (the hazard `record_fn_write`'s own doc comment calls
3839    /// out for its `Param` arm, by the same reasoning). No such fixture is
3840    /// pinned here; this is a known gap in this pinning pass, not a claim
3841    /// that the guard is unnecessary.
3842    #[test]
3843    fn a_ref_param_write_to_an_unrelated_global_root_does_not_poison_a_traced_local() {
3844        let src = "VAR total = 0\nVAR npc = 5\n\
3845                   === function bar() ===\n~ total = total + 1\n~ return total\n\
3846                   === function poke(ref g, h) ===\n~ g = h\n\
3847                   === function user(cond, new_cb) ===\n~ temp f = #fn(bar)\n\
3848                   {cond:\n  ~ poke(npc, new_cb)\n}\n~ return f()\n";
3849        let (hir, index, res) = build(src);
3850        let files = [(FileId(0), &hir)];
3851        let rows = effects_project(&files, &index, &res, None);
3852        let user = id_of(&index, "user");
3853        let total = id_of(&index, "total");
3854        assert!(
3855            !rows[&user].opaque,
3856            "a ref-param write to an unrelated global root must not poison \
3857             `f`'s own fully traced write set: {:?}",
3858            rows[&user]
3859        );
3860        assert!(
3861            rows[&user].writes.contains(&total),
3862            "the narrowed call through f must still join bar's own write to \
3863             total: {:?}",
3864            rows[&user]
3865        );
3866    }
3867
3868    // ─── Issue #1755: channel 5's VAR case — the `#fn`-creation-site
3869    // `ref` binding ──────────────────────────────────────────────────────
3870    //
3871    // docs/effects-spec.md §6.1a enumerated this as the one aliasing channel
3872    // that was a genuine conservative-total (§3) *under*-report rather than a
3873    // deliberate pessimal fallback: `#fn(heal, player_hp)` binds `heal`'s
3874    // `ref hp` param to the cell `player_hp` at the **creation** site, a
3875    // grammar position distinct from a call site's `ref` argument, and
3876    // `infer_fn_literal` never called `record_ref_param_writes`. The write
3877    // was therefore recorded nowhere — not at the creation site, not in
3878    // `heal`'s own body (where `hp` resolves as a `Param`, never a
3879    // `Variable`), and not at the eventual `f(5)` call site (which carries no
3880    // record of which cell `heal` was created against).
3881
3882    /// The under-report itself: creating a fn value that binds a `ref` param
3883    /// to a `VAR` must fold that cell into the *creating* body's own write
3884    /// set. Option (a) of #1755's ask — sound (the write genuinely happens
3885    /// when the value is called) though coarse (it is charged at the creation
3886    /// site whether or not the value is ever called). Over-reporting is the
3887    /// permitted direction (§3).
3888    #[test]
3889    fn a_fn_creation_site_ref_binding_records_the_bound_cell_as_a_write() {
3890        let src = "VAR player_hp = 10\n\
3891                   === function heal(ref hp, amount) ===\n~ hp = hp + amount\n\
3892                   === function user() ===\n~ temp f = #fn(heal, player_hp)\n\
3893                   ~ return f(5)\n";
3894        let (hir, index, res) = build(src);
3895        let files = [(FileId(0), &hir)];
3896        let rows = effects_project(&files, &index, &res, None);
3897        let user = id_of(&index, "user");
3898        let player_hp = id_of(&index, "player_hp");
3899        assert!(
3900            rows[&user].writes.contains(&player_hp),
3901            "the cell bound into `heal`'s ref param at the `#fn` creation site \
3902             is genuinely written when the created value runs — omitting it \
3903             from `user`'s row is the under-report §3 forbids: {:?}",
3904            rows[&user]
3905        );
3906    }
3907
3908    /// The same recording must happen even when the created value is never
3909    /// called from the creating body at all — the bound cell escapes with the
3910    /// value (returned here), so the creating body is the only place that can
3911    /// still see which cell was bound. Charging the write at the creation
3912    /// site is exactly what makes that possible.
3913    #[test]
3914    fn a_fn_creation_site_ref_binding_records_the_write_even_when_never_called() {
3915        let src = "VAR player_hp = 10\n\
3916                   === function heal(ref hp, amount) ===\n~ hp = hp + amount\n\
3917                   === function user() ===\n~ return #fn(heal, player_hp)\n";
3918        let (hir, index, res) = build(src);
3919        let files = [(FileId(0), &hir)];
3920        let rows = effects_project(&files, &index, &res, None);
3921        let user = id_of(&index, "user");
3922        let player_hp = id_of(&index, "player_hp");
3923        assert!(
3924            rows[&user].writes.contains(&player_hp),
3925            "a created-but-uncalled `#fn` still binds the cell — the creation \
3926             site is the only place the binding is visible: {:?}",
3927            rows[&user]
3928        );
3929    }
3930
3931    /// Channel 4's root-unwrapping applies at this grammar position too: an
3932    /// explicit `ref` projection (`ref npc.hp`, T1e) bound at a creation site
3933    /// writes through the **root** global's own cell, exactly as
3934    /// `record_ref_param_writes` already unwraps it at a call site.
3935    #[test]
3936    fn a_fn_creation_site_ref_projection_records_its_root_cell() {
3937        let src = "VAR npc = 0\n\
3938                   === function heal(ref hp, amount) ===\n~ hp = hp + amount\n\
3939                   === function user() ===\n~ temp f = #fn(heal, ref npc.hp)\n\
3940                   ~ return f(5)\n";
3941        let (hir, index, res) = build(src);
3942        let files = [(FileId(0), &hir)];
3943        let rows = effects_project(&files, &index, &res, None);
3944        let user = id_of(&index, "user");
3945        let npc = id_of(&index, "npc");
3946        assert!(
3947            rows[&user].writes.contains(&npc),
3948            "mutating a projection writes through the root global's own cell: \
3949             {:?}",
3950            rows[&user]
3951        );
3952    }
3953
3954    /// The pessimal floor must not *widen* on the way through (the PR #1731
3955    /// review lesson): a `#fn` creation site whose bound prefix contains no
3956    /// `ref` param at all is untouched by this fix — the local still narrows
3957    /// to its traced target rather than falling to `opaque`.
3958    #[test]
3959    fn a_fn_creation_site_without_a_ref_param_still_narrows() {
3960        let src = "VAR total = 0\n\
3961                   === function bar(n) ===\n~ total = total + n\n~ return total\n\
3962                   === function user() ===\n~ temp f = #fn(bar, 1)\n~ return f()\n";
3963        let (hir, index, res) = build(src);
3964        let files = [(FileId(0), &hir)];
3965        let rows = effects_project(&files, &index, &res, None);
3966        let user = id_of(&index, "user");
3967        let total = id_of(&index, "total");
3968        assert!(
3969            !rows[&user].opaque,
3970            "a non-`ref` bound prefix must not be charged as an untraced \
3971             write — the local still narrows to `bar`: {:?}",
3972            rows[&user]
3973        );
3974        assert!(
3975            rows[&user].writes.contains(&total),
3976            "the narrowed call through f still joins bar's own write: {:?}",
3977            rows[&user]
3978        );
3979    }
3980
3981    // ─── Fork A (docs/decision-log.md 2026-07-28, issue #1726): the
3982    // structural fn-value creation atom ─────────────────────────────────
3983
3984    /// The atom itself: `#fn(target, …)` — bare, `bind`-wrapped, or never
3985    /// called at all — records `target` in `EffectAtoms::creates_fn_values`,
3986    /// and a body with no `#fn` literal records nothing. Harvested by the
3987    /// same empty-globals/empty-sigs walk every other structural atom uses,
3988    /// so no inferred row or signature is ever consulted to decide an edge.
3989    #[test]
3990    fn fn_value_creation_sites_are_harvested_as_a_structural_atom() {
3991        let src = "VAR total = 0\n\
3992                   === function bar(n) ===\n~ total = total + n\n~ return total\n\
3993                   === function baz() ===\n~ return 0\n\
3994                   === function creates() ===\n~ temp f = #fn(bar, 1)\n~ return call(f)\n\
3995                   === function binds() ===\n~ return call(bind(#fn(bar), 5))\n\
3996                   === function hands_out() ===\n~ return #fn(baz)\n\
3997                   === function plain() ===\n~ return bar(1)\n";
3998        let (hir, index, res) = build(src);
3999        let files = [(FileId(0), &hir)];
4000        let inferable = inferable_defs(&files, &index);
4001        let bar = id_of(&index, "bar");
4002        let baz = id_of(&index, "baz");
4003
4004        let atoms = |name: &str| {
4005            def_effect_atoms(id_of(&index, name), &files, &index, &res, &inferable, None)
4006        };
4007
4008        assert!(
4009            atoms("creates").creates_fn_values.contains(&bar),
4010            "a bare #fn literal is a creation site"
4011        );
4012        assert!(
4013            atoms("binds").creates_fn_values.contains(&bar),
4014            "bind() copies a value rather than naming a target — the nested \
4015             #fn literal is what gets recorded"
4016        );
4017        assert!(
4018            atoms("hands_out").creates_fn_values.contains(&baz),
4019            "a fn value that is created and returned, never called here, is \
4020             still a creation site"
4021        );
4022        assert!(
4023            atoms("plain").creates_fn_values.is_empty(),
4024            "a direct call creates no fn value"
4025        );
4026    }
4027
4028    /// `creates_fn_values` is a subset of `direct_calls` by construction —
4029    /// the same walk records a `#fn` target as a call-graph edge, which is
4030    /// exactly how these edges reach the SCC batching and `solve_scc_effects`
4031    /// with no change to either. Pinned so a future edit cannot quietly break
4032    /// the batching invariant `effects_project`'s graph relies on.
4033    #[test]
4034    fn every_fn_value_creation_target_is_also_a_call_graph_edge() {
4035        let src = "VAR total = 0\n\
4036                   === function bar() ===\n~ total = total + 1\n~ return total\n\
4037                   === function hands_out() ===\n~ return #fn(bar)\n";
4038        let (hir, index, res) = build(src);
4039        let files = [(FileId(0), &hir)];
4040        let inferable = inferable_defs(&files, &index);
4041        let atoms = def_effect_atoms(
4042            id_of(&index, "hands_out"),
4043            &files,
4044            &index,
4045            &res,
4046            &inferable,
4047            None,
4048        );
4049        assert!(
4050            atoms.creates_fn_values.is_subset(&atoms.direct_calls),
4051            "creation targets must also be call-graph edges: {:?} ⊄ {:?}",
4052            atoms.creates_fn_values,
4053            atoms.direct_calls
4054        );
4055    }
4056
4057    /// An `EXTERNAL` `#fn` target is deliberately not a creation-atom member:
4058    /// it has no inferable body, so it is not a legal call-graph edge. The
4059    /// call-kind atom is still recorded (`record_call_edge`'s external arm),
4060    /// so nothing is silently dropped — see `record_fn_value_creation`'s doc.
4061    #[test]
4062    fn an_external_fn_value_target_is_a_call_kind_atom_not_a_creation_edge() {
4063        let src = "EXTERNAL play_sfx(x)\n\
4064                   === function hands_out() ===\n~ return #fn(play_sfx)\n";
4065        let (hir, index, res) = build(src);
4066        let files = [(FileId(0), &hir)];
4067        let inferable = inferable_defs(&files, &index);
4068        let atoms = def_effect_atoms(
4069            id_of(&index, "hands_out"),
4070            &files,
4071            &index,
4072            &res,
4073            &inferable,
4074            None,
4075        );
4076        assert!(
4077            atoms.creates_fn_values.is_empty(),
4078            "an EXTERNAL target has no row to follow, so it is not an edge"
4079        );
4080        assert!(
4081            atoms.calls.contains("play_sfx"),
4082            "the call-kind atom is still harvested — no silent drop"
4083        );
4084    }
4085
4086    /// A def that creates a fn value and hands it out without ever calling it
4087    /// still carries the target's row, because §6.1 fixes the value's row at
4088    /// its creation site. This is what makes a downstream `opaque` collapse
4089    /// worth having — the effects are already attributed where the value was
4090    /// born.
4091    ///
4092    /// **This behavior predates #1726** and is pinned here, not introduced:
4093    /// `infer_fn_literal` already routed every `#fn` target through
4094    /// [`InferPass::record_call_edge`], so the graph edge existed before the
4095    /// creation atom did. `creates_fn_values` is therefore a strict subset of
4096    /// `direct_calls` and adds no new edge today — its value is making the
4097    /// creation fact *addressable* (spec §7's token table, §8 rung 1's
4098    /// reachability slicing) and guaranteeing the property stays true. The
4099    /// guard is `every_fn_value_creation_target_is_also_a_call_graph_edge`;
4100    /// this test pins that the atom did not disturb the row it rides on.
4101    #[test]
4102    fn creating_a_fn_value_joins_the_targets_row_even_without_a_call() {
4103        let src = "VAR total = 0\n\
4104                   === function bar() ===\n~ total = total + 1\n~ return total\n\
4105                   === function hands_out() ===\n~ return #fn(bar)\n";
4106        let (hir, index, res) = build(src);
4107        let files = [(FileId(0), &hir)];
4108        let rows = effects_project(&files, &index, &res, None);
4109        let hands_out = id_of(&index, "hands_out");
4110        let total = id_of(&index, "total");
4111        assert!(
4112            rows[&hands_out].writes.contains(&total),
4113            "the creation edge must pull bar's row into hands_out"
4114        );
4115        assert!(
4116            !rows[&hands_out].opaque,
4117            "creating a fn value is not itself an opaque construct"
4118        );
4119    }
4120
4121    /// Transitive composition: narrowing must feed the *same* SCC effect
4122    /// fixpoint a direct call edge does, so a callee-of-the-callee's atoms
4123    /// still propagate all the way up through the narrowed edge — not just
4124    /// the immediately-dispatched def's own atoms.
4125    #[test]
4126    fn narrowed_call_composes_transitively_through_the_callees_own_callee() {
4127        let src = "VAR total = 0\n\
4128                   === function baz() ===\n~ total = total + 1\n~ return total\n\
4129                   === function bar() ===\n~ return baz()\n\
4130                   === function user() ===\n~ temp f = #fn(bar)\n~ return f()\n";
4131        let (hir, index, res) = build(src);
4132        let files = [(FileId(0), &hir)];
4133        let rows = effects_project(&files, &index, &res, None);
4134        let user = id_of(&index, "user");
4135        let total = id_of(&index, "total");
4136        assert!(
4137            !rows[&user].opaque,
4138            "narrowing to bar must not itself force pessimal"
4139        );
4140        assert!(
4141            rows[&user].writes.contains(&total),
4142            "bar's own row already transitively covers baz's write to total \
4143             (ordinary direct-call SCC propagation) — user's narrowed edge to \
4144             bar must inherit that whole row, not just bar's own direct atoms"
4145        );
4146    }
4147
4148    /// The pre-existing pessimal-floor regression must hold unchanged: an
4149    /// `Unknown`-typed callee (a param with no traceable origin at all) still
4150    /// gets no narrowing — `local_call_origin` never classifies a param as
4151    /// `Local`, so #872's write-summary rung does not apply to it and the
4152    /// floor holds regardless of write count.
4153    ///
4154    /// §6.1 (issue #1680) added the *other* way out — a row variable the
4155    /// caller instantiates — which is why the assertion is `is_pessimal()`:
4156    /// the definition read on its own is exactly as unbounded as it was.
4157    #[test]
4158    fn a_call_through_an_unresolvable_param_stays_pessimal() {
4159        let src = "=== function apply(cb) ===\n~ return cb(1)\n";
4160        let (hir, index, res) = build(src);
4161        let files = [(FileId(0), &hir)];
4162        let rows = effects_project(&files, &index, &res, None);
4163        let apply = id_of(&index, "apply");
4164        assert!(
4165            rows[&apply].is_pessimal(),
4166            "a call through a function value with no known origin must stay pessimal"
4167        );
4168    }
4169
4170    /// Soundness regression (review finding on #872's initial landing): a
4171    /// `Param` carries an implicit caller-provided initial value that
4172    /// `local_write_counts` never sees. If a param is reassigned exactly
4173    /// once inside the body, its whole-body write count reaches 1 — but any
4174    /// call site *reachable before* that reassignment still runs against
4175    /// the caller's arbitrary (unknown) fn value, not the known origin the
4176    /// single write traces to. `local_call_origin` narrowing a `Param` the
4177    /// same way it narrows a write-once `Temp` would incorrectly narrow
4178    /// that earlier call site too, under-reporting whatever effects the
4179    /// caller's actual callee has that `bar` doesn't. `apply`'s row must
4180    /// stay opaque: `cb` is a `Param`, never eligible for `Local` narrowing
4181    /// regardless of how many times it's written.
4182    #[test]
4183    fn a_param_reassigned_once_called_before_the_write_stays_pessimal() {
4184        let src = "VAR total = 0\n\
4185                   === function bar(n) ===\n~ total = total + n\n~ return total\n\
4186                   === function apply(cb, guard) ===\n\
4187                   {guard:\n  ~ return cb(1)\n}\n~ cb = #fn(bar)\n~ return cb(1)\n";
4188        let (hir, index, res) = build(src);
4189        let files = [(FileId(0), &hir)];
4190        let rows = effects_project(&files, &index, &res, None);
4191        let apply = id_of(&index, "apply");
4192        assert!(
4193            rows[&apply].opaque,
4194            "a param reassigned exactly once inside the body must not narrow \
4195             calls reachable before that reassignment — the param still holds \
4196             the caller's arbitrary fn value there"
4197        );
4198    }
4199
4200    // ─── Issue #1027: `type_ref_to_ty` and `external_check::resolve_type`
4201    // agree on unregistered semantic-type names ──────────────────────────
4202
4203    /// The #1004/#1027 case, exercised through both real call sites for the
4204    /// exact same input: an `EXTERNAL` param typed via inline `@param` doc
4205    /// with a semantic-type name (`var_id`) the registered manifest does
4206    /// *not* define (the manifest defines `actor_id`, a sibling type, so
4207    /// the vocabulary genuinely reached the analyzer — this isn't the
4208    /// "no manifest at all" tolerant case). `collect_external_sigs`
4209    /// (consumed by strict inference) and `external_check::analyze_externals`
4210    /// (consumed by hover/signature help) must both call `var_id`
4211    /// unresolved: `Ty::Unknown` on one side, `ResolvedType { base: None,
4212    /// .. }` on the other — never a confidently-resolved type on either
4213    /// side. Both now delegate the base/registered/unregistered decision to
4214    /// the same `type_resolution::classify` helper, so this is a genuine
4215    /// agreement check, not a coincidence of two independently-written
4216    /// `match`es.
4217    #[test]
4218    fn collect_external_sigs_and_resolve_type_agree_on_an_unregistered_semantic_type() {
4219        let (_hir, index, _res, inline_docs) =
4220            build_with_docs("/// @param id {var_id}\nEXTERNAL get_variable(id)\n-> DONE\n");
4221        let manifest = brink_ir::HostManifest {
4222            markup: Vec::new(),
4223            types: vec![brink_ir::SemanticTypeDef {
4224                name: "actor_id".to_string(),
4225                base: brink_ir::BaseType::String,
4226                constraint: None,
4227                values: None,
4228                widget: None,
4229            }],
4230            externals: Vec::new(),
4231        };
4232
4233        // Strict-inference side.
4234        let sigs = collect_external_sigs(&index, Some(&manifest), &inline_docs);
4235        let ext_id = index
4236            .by_name
4237            .get("get_variable")
4238            .and_then(|ids| ids.first())
4239            .copied()
4240            .expect("get_variable in index");
4241        let sig = sigs.get(&ext_id).expect("seeded signature");
4242        assert_eq!(
4243            sig.params,
4244            vec![Ty::Unknown],
4245            "var_id is not registered — strict inference must not fabricate a type"
4246        );
4247
4248        // Hover/signature-help side — same index, same inline_docs, same
4249        // registered `types` vocabulary (`actor_id` only).
4250        let (types, registered) = crate::manifest_maps(Some(&manifest));
4251        let (metas, diags) = crate::external_check::analyze_externals(
4252            &index,
4253            &inline_docs,
4254            &types,
4255            &registered,
4256            crate::ExternalCheckSeverity::Error,
4257            true, // manifest registered → unknown types are checked (E040)
4258        );
4259        let meta = metas.get(&ext_id).expect("meta for get_variable");
4260        assert!(
4261            meta.params[0]
4262                .ty
4263                .as_ref()
4264                .is_some_and(|t| !t.is_registered()),
4265            "var_id must render as unregistered (base: None), not a confident type: {:?}",
4266            meta.params[0].ty
4267        );
4268        assert_eq!(
4269            diags.len(),
4270            1,
4271            "the same unregistered name also raises E040 on this path: {diags:?}"
4272        );
4273        assert_eq!(diags[0].code, brink_ir::DiagnosticCode::E040);
4274    }
4275}