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 ®istered,
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}