Skip to main content

brink_analyzer/
signature.rs

1//! `signature(def)` — per-declaration signature summary.
2//!
3//! Phase-0 **stub** of the `signature(DefId)` query (scripting-substrate
4//! spec §4, layer 2): it carries only what is already derivable from the
5//! declaration itself — name, kind, declared params, the initializer-inferred
6//! type (the same inference as [`crate::external_check::infer_value_meta`]),
7//! and the `#@local` flow-private bit. No checking, no body analysis. The
8//! future type checker reads this as its firewall unit.
9
10use std::sync::Arc;
11
12use brink_format::DefinitionId;
13use brink_ir::hir::{Expr, HirFile};
14use brink_ir::{FileId, HostManifest, ParamInfo, SymbolIndex, SymbolKind};
15
16use crate::annotations::resolve as resolve_annotation;
17use crate::external_check::{InferredType, infer_literal_type};
18use crate::infer::Ty;
19use crate::resolve::{BareItemResult, lookup_by_name, lookup_list_item_bare};
20
21/// Per-declaration signature summary (phase-0 stub).
22///
23/// Everything here is declaration-derived: a body edit that doesn't touch
24/// the declaration line(s) must not change a `Sig`.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Sig {
27    /// Canonical/qualified name, as indexed (e.g. `knot.stitch`).
28    pub name: String,
29    /// Declaration kind.
30    pub kind: SymbolKind,
31    /// Declared parameters (knots, stitches, externals).
32    pub params: Vec<ParamInfo>,
33    /// Type inferred from the initializer literal (VAR/CONST only), if the
34    /// initializer is one. TM-2 (docs/typed-mode-spec.md §3): when the
35    /// `VAR`/`CONST` also carries a `: type` annotation and that annotation
36    /// resolves to a representable [`InferredType`], the annotation
37    /// *replaces* this value — annotation wins over inference (the
38    /// firewall rule) — so every existing consumer of `value_type`
39    /// (notably `infer::collect_globals`) picks up the annotated type
40    /// automatically, with no seam change.
41    ///
42    /// [`local_signature`]'s `Param`/`Temp` locals populate this field too
43    /// (issue #530) — there it is never initializer-inferred (a local has
44    /// no initializer-literal path here), only the downcast of the local's
45    /// own TM-2 `: type` annotation, `None` when unannotated or unresolved.
46    pub value_type: Option<InferredType>,
47    /// A VAR/CONST's declaration-derived type at **full [`Ty`] fidelity**
48    /// (issue #1540) — `None` for every other `signature`-produced symbol
49    /// kind, and for a VAR/CONST whose declaration determines no type at
50    /// all. [`local_signature`] (issue #530) is the one other producer that
51    /// populates this field: for a `Param`/`Temp`, it is the local's own
52    /// TM-2 `: type` annotation resolved the same way, `None` when the
53    /// local is unannotated — this is precisely the field
54    /// `brink-ide::hover::inferred_local_type_str` reads via
55    /// `db.local_signature`.
56    ///
57    /// This is the field every *typed* consumer reads (`collect_globals` in
58    /// both this crate and `brink-db`'s narrowed mirror); `value_type` above
59    /// stays exactly as narrow as it was, because it is the wire-adjacent
60    /// domain `infer_value_meta`/hover share and widening it would leak
61    /// `Array`/`Map`/`Struct`/`Fn`/`Option`/`Range` into that schema.
62    ///
63    /// Populated in TM-2 firewall order — annotation first, then the
64    /// initializer:
65    /// - an explicit `: type` annotation on the VAR/CONST, resolved by
66    ///   [`crate::annotations::resolve`] with **no downcast** (so
67    ///   `Array<int>`, `Map<string, int>`, a declared `STRUCT` name,
68    ///   `fn(T…): R`, `Handle<K>` and (since issue #1552) `Option<T>`/
69    ///   `Weighted<T>` all survive — this is the `ty_to_inferred_type` gap
70    ///   issue #1540 closes. `range` still has **no annotation grammar at
71    ///   all** ([`crate::annotations::resolve`] has no arm for it — deferred
72    ///   pending demonstrated demand, `docs/decision-log.md` 2026-07-27), so
73    ///   a `Ty::Range` value can never actually reach this field yet;
74    /// - else the initializer literal, at the same fidelity
75    ///   ([`literal_ty`]): `#[…]` → `Ty::Array`, `#{…}` → `Ty::Map`,
76    ///   `Name#{…}` → `Ty::Struct`, plus every scalar/`List<L>` form
77    ///   `infer_literal_type` already covered;
78    /// - else a fn-value initializer ([`declared_fn_type`]) — a bare
79    ///   `#fn(target, args…)` literal (T1c follow-up, issue #712,
80    ///   docs/t1c-spec.md §4), or, on the **native** surface only, a bare
81    ///   name resolving to a function definition (docs/t1c-spec.md §2a,
82    ///   issue #1895; zero bound args, since the binding form has no native
83    ///   spelling). Either way the type is the bound prefix consumed from
84    ///   `target`'s *own* declaration-derived signature
85    ///   (`param_annotations`/`return_annotation` — a second, single-level
86    ///   `signature()` call, never the target's body). An unannotated target
87    ///   param/return reads as `Ty::Unknown` in the row, the same
88    ///   conservative fallback declaration-derived typing always uses.
89    pub value_ty: Option<Ty>,
90    /// Marked flow-private via a `#@local` directive (knots, stitches, VARs).
91    pub is_local: bool,
92    /// TM-2 (docs/typed-mode-spec.md §3): declared param type annotations,
93    /// positional (parallel to `params`) — `None` per-slot for an
94    /// unannotated param, or one whose annotation doesn't resolve to a `Ty`
95    /// (`void`, `fn(...)`, an unrecognized name — `crate::check_annotations`
96    /// reports those separately). Knots/stitches only; empty for other
97    /// symbol kinds.
98    pub param_annotations: Vec<Option<Ty>>,
99    /// TM-2: the function-header/stitch-header return type annotation
100    /// (`): type ===` on a knot, `: type` on a stitch — #1509 widened the
101    /// grammar to stitches), resolved. `None` when absent, `void`, or
102    /// unresolved. Knots and stitches only.
103    pub return_annotation: Option<Ty>,
104}
105
106/// Downcast a resolved [`Ty`] to [`InferredType`] where the two universes
107/// overlap (`Ty`'s scalar leaves plus nominal `List<L>`) — the annotation-
108/// wins substitution for `Sig::value_type`, which predates TM-2 and only
109/// has room for `InferredType`'s narrower domain (hover + the `@brink-lang/web`
110/// program model read it; widening it would change that schema).
111///
112/// Every `Ty` outside that overlap — `Array<T>`, `Map<K, V>`, a nominal
113/// `STRUCT`, `fn(T…): R`, `Handle<K>`, `Option<T>`, `range`, `Weighted<T>`,
114/// a tower kind — is **not** dropped: since issue #1540 it is carried at
115/// full fidelity on [`Sig::value_ty`], which is what `infer::collect_globals`
116/// (and `brink-db`'s narrowed mirror of it) reads to give a global its static
117/// type. `param_annotations`/`return_annotation`/`crate::resolve_annotation`
118/// remain the equivalent full-fidelity surfaces for knots/stitches and for
119/// any bare `TypeExpr`.
120fn ty_to_inferred_type(ty: &Ty) -> Option<InferredType> {
121    match ty {
122        Ty::Int => Some(InferredType::Int),
123        Ty::Float => Some(InferredType::Float),
124        Ty::Bool => Some(InferredType::Bool),
125        Ty::String => Some(InferredType::String),
126        Ty::Divert => Some(InferredType::Divert),
127        Ty::List(name) => Some(InferredType::List(name.clone())),
128        // `Conflicted` (#627): a genuine type conflict has no representable
129        // `InferredType` any more than `Unknown` does — this stub is a
130        // gradual/advisory consumer, so it reads both the same way.
131        // Everything below has no `InferredType` variant to downcast to —
132        // `Array`/`Map` (T1b), `Struct` (TM-4b), `Fn` (T1c), `Handle`
133        // (T1d-2), `Option` (NS-A1), `Range` (NS-A5), `Weighted` (NS-A7),
134        // `Tower` (NS-A8), `Content` (issue #1846). None of them is a silent
135        // drop: since issue #1540 each is carried whole on `Sig::value_ty`
136        // (see this function's doc), which is the field every typed
137        // consumer reads. Only the narrow, wire-adjacent `Sig::value_type`
138        // stops here.
139        Ty::Weighted(_)
140        | Ty::Tower(_)
141        | Ty::Array(_)
142        | Ty::Map(_, _)
143        | Ty::Struct(_)
144        | Ty::Fn(..)
145        | Ty::Handle(_)
146        | Ty::Option(_)
147        | Ty::Range { .. }
148        | Ty::Content
149        | Ty::Unknown
150        | Ty::Conflicted => None,
151    }
152}
153
154/// TM-2 firewall rule, shared by `VAR` and `CONST`: if `annotation` resolves
155/// to a representable [`InferredType`], it replaces `literal_type`; otherwise
156/// the literal-inferred type stands.
157fn value_type_with_annotation_override(
158    literal_type: Option<InferredType>,
159    annotation: Option<&brink_ir::TypeExpr>,
160    names: &crate::annotations::TypeNames,
161) -> Option<InferredType> {
162    annotation
163        .and_then(|ann| resolve_annotation(ann, names))
164        .and_then(|ty| ty_to_inferred_type(&ty))
165        .or(literal_type)
166}
167
168/// A VAR/CONST initializer literal's type at full [`Ty`] fidelity (issue
169/// #1540) — the collection-aware sibling of
170/// [`infer_literal_type`](crate::external_check::infer_literal_type), which
171/// stops at [`InferredType`]'s scalar/`List<L>` domain.
172///
173/// Policy parity with `infer::body`'s own per-body literal arms is
174/// deliberate and load-bearing: this declaration-derived stub and the real
175/// HM inference must never disagree about what `#[1, 2, 3]` is, or a
176/// global and a `temp` holding the same literal would type differently.
177/// So the element/key/value joins go through the same
178/// [`unify_all`](crate::infer::unify_all) the body arms use (an empty or
179/// mixed literal lands on `Ty::Unknown`/`Ty::Conflicted` identically), and
180/// a construction literal's type is its written shape name, exactly as
181/// `Expr::StructLiteral`'s arm has it — construction *validity* is
182/// `crate::structs`' job, not this function's.
183///
184/// Nested elements recurse through this same function rather than through
185/// body inference: a declaration default is constant-folded, so the only
186/// element forms that can carry a type here are literals (a call/index/
187/// field access in that position is already `E077`).
188///
189/// Deliberately **not** handled: `Expr::Range`. A range literal never
190/// constant-folds into a declaration default at all (`brink-ir`'s
191/// `lir::lower::decls::is_const_foldable_decl_default` returns `false` for
192/// it), so typing one here would mint `NonEmptyRange` evidence — or its
193/// absence — for a declaration that cannot compile. Moot in practice: a
194/// `range` *annotation* has no grammar either
195/// ([`crate::annotations::resolve`] has no `"range"` arm), so
196/// `declared_value_ty`'s annotation branch can't produce `Ty::Range` any
197/// more than this literal branch can.
198///
199/// `pub(crate)` (issue #1877) so `strict::check_one_global_initializer` can
200/// independently type a VAR/CONST initializer literal and compare it
201/// against the declaration's own explicit annotation — the exact TM-2
202/// firewall comparison `declared_value_ty` below never makes, since there
203/// the annotation *replaces* this value outright rather than being checked
204/// against it.
205pub(crate) fn literal_ty(expr: &Expr, index: &SymbolIndex) -> Option<Ty> {
206    match expr {
207        Expr::ArrayLiteral(a) => Some(Ty::Array(Box::new(crate::infer::unify_all(
208            a.elements
209                .iter()
210                .map(|e| literal_ty(e, index).unwrap_or(Ty::Unknown)),
211        )))),
212        Expr::MapLiteral(m) => {
213            let keys = m
214                .entries
215                .iter()
216                .map(|(k, _)| literal_ty(k, index).unwrap_or(Ty::Unknown));
217            let vals: Vec<Ty> = m
218                .entries
219                .iter()
220                .map(|(_, v)| literal_ty(v, index).unwrap_or(Ty::Unknown))
221                .collect();
222            Some(Ty::Map(
223                Box::new(crate::infer::unify_all(keys)),
224                Box::new(crate::infer::unify_all(vals)),
225            ))
226        }
227        Expr::StructLiteral(sl) => Some(Ty::Struct(sl.shape.text.clone())),
228        _ => infer_literal_type(expr, index).map(Ty::from),
229    }
230}
231
232/// A VAR/CONST's declaration-derived type at full [`Ty`] fidelity — the
233/// value behind [`Sig::value_ty`] (issue #1540). Resolution order is the
234/// TM-2 firewall's: an explicit annotation wins outright, then the
235/// initializer literal, then a fn-value initializer.
236///
237/// `native` is the declaring file's frontend flag ([`HirFile::native`]) —
238/// the one gate the native bare-name spelling needs (issue #1895); see
239/// [`declared_fn_type`].
240fn declared_value_ty(
241    value: &Expr,
242    annotation: Option<&brink_ir::TypeExpr>,
243    index: &SymbolIndex,
244    files: &[(FileId, &HirFile)],
245    names: &crate::annotations::TypeNames,
246    manifest: Option<&HostManifest>,
247    native: bool,
248) -> Option<Ty> {
249    if let Some(ty) = annotation.and_then(|ann| resolve_annotation(ann, names)) {
250        return Some(ty);
251    }
252    literal_ty(value, index).or_else(|| declared_fn_type(value, index, files, manifest, native))
253}
254
255/// A VAR/CONST's declaration-derived fn-value initializer type (T1c
256/// follow-up, issue #712) — `None` when the initializer is neither an ink
257/// `#fn(target, args…)` literal nor, on the native surface, a bare name
258/// resolving to a function definition. Feeds [`Sig::value_ty`]'s last
259/// fallback, after the annotation and the initializer-literal branches in
260/// [`declared_value_ty`] have both already come up empty — an `fn(...)`
261/// *annotation* is resolved and returned there directly (that call site's
262/// own `if let Some(ty) = annotation.and_then(...)` is strictly earlier in
263/// the firewall order and already covers it), so this function only ever
264/// needs to look at the initializer.
265///
266/// **Two spellings, one type** (`docs/t1c-spec.md` §2a). Issue #1895 closed
267/// the declaration-initializer half of the native bare-name rule: lowering
268/// has always minted the fn value here (`lir::lower::decls::fold_path_ref`
269/// → `ConstValue::FnRef`, gated on `native && is_function_definition`) and
270/// `fn_values::check_native_bare_refs` has always walked decl initializers
271/// for `E080`, but typing had no native arm at all — so `Sig::value_ty`
272/// stayed `None`, the global never reached `infer::collect_globals`,
273/// `ty_of_def` answered `Ty::Unknown`, and a later `f(3)` misfired `E065`
274/// on correct code. The bare-name arm below is gated on the *same* two
275/// conjuncts lowering uses, so the two can never disagree about which
276/// initializers are fn values *when the target resolves to an actual
277/// knot* — the lookup below is restricted to `&[SymbolKind::Knot]`,
278/// narrower than lowering's `is_function_definition` (`Knot | Stitch`),
279/// so a top-level stitch promoted to knot status is a known, still-open
280/// one-sided gap (`docs/t1c-spec.md` §2a). A bare name always binds
281/// **zero** arguments — the binding form `#fn(f, a)` has no native
282/// spelling (§2a) — so the value's parameter row is the target's whole
283/// signature.
284fn declared_fn_type(
285    value: &Expr,
286    index: &SymbolIndex,
287    files: &[(FileId, &HirFile)],
288    manifest: Option<&HostManifest>,
289    native: bool,
290) -> Option<Ty> {
291    // Targets are always a single, statically-named function knot — never
292    // a stitch (T1c-1's own note: "every stitch target rejects until
293    // stitch-functions exist"), never a local (a global initializer has no
294    // enclosing body to scope a temp/param against). This is exactly
295    // `resolve::resolve_function`'s own first-tried bucket for a bare
296    // name, so it agrees with the real resolver for every target that
297    // isn't already an E079 error; an E079 target (wrong kind, or no
298    // "function" detail) falls through to `None` — the pre-existing
299    // Unknown-escape fallback, not a new failure mode.
300    let (target_path, bound_args) = match value {
301        Expr::FnLiteral(fl) => (&fl.target, fl.args.len()),
302        // Issue #1895: the sigil-free native spelling. Zero bound args,
303        // and only on the native surface — in ink the same bare name is a
304        // knot's visit count, never a fn value (§2a, "Ink is unchanged").
305        Expr::Path(path) if native => (path, 0),
306        _ => return None,
307    };
308    let target_name = target_path
309        .segments
310        .iter()
311        .map(|s| s.text.as_str())
312        .collect::<Vec<_>>()
313        .join(".");
314    // A bare `Path` initializer is the one form whose *own* resolution is
315    // not decided here: `lower_path`/`fold_path_ref` consult the real
316    // resolution map, which reaches a same-named VAR/CONST/list item
317    // before it reaches a function knot. `signature()` has no resolution
318    // map (declaration-derived only), so rather than guess the wrong
319    // interpretation for a shadowed name, decline — `None` is the honest
320    // "can't determine" and leaves the pre-existing `Ty::Unknown`
321    // behaviour exactly as it was. The `#fn` literal form is unaffected:
322    // its target grammar admits nothing but a function name.
323    //
324    // This check is **project index-wide**, not scoped to what this
325    // declaration can actually see — issue #1901 asked whether that could
326    // disagree with `fold_path_ref`'s real, `ImportScope`-aware
327    // resolution in a way that is user-visible: a same-named global in an
328    // unrelated, non-importing file suppressing this typing even though
329    // lowering would still resolve the bare name to the local knot. For
330    // the cross-file case it cannot: `lower_native::decl::{lower_var_decl,
331    // lower_const_decl, lower_flags_decl}` hard-code `visibility: None`
332    // for every native `VAR`/`CONST`/`LIST` (no annotation grammar
333    // overrides it, unlike ink's `#@public`/`#@private` tags), and a
334    // native file is unconditionally its own module
335    // (`brink_db::modules::native_module_path`), so a same-named
336    // `VAR`/`CONST`/`ListItem` declared in a *different* `.brink` file can
337    // never be legitimately referenced at all — `modules::check`'s `E087`
338    // fires unconditionally the moment resolution reaches it (confirmed
339    // by `native_cross_file_global_shadow_of_a_fn_value_reference_fails_to_compile`,
340    // `crates/brink-compiler/tests/driver.rs`), failing the whole compile
341    // before this decision could ever matter.
342    //
343    // The **same-file** case is different, and matters: `ListItem`s are
344    // indexed under their *qualified* name (`List.Item`, `manifest.rs`'s
345    // `index.by_name.entry(sym.name.clone())`), never their bare item
346    // name, so a direct `index.by_name.get(target_name)` lookup can never
347    // find a bare-name list item — the `SymbolKind::ListItem` arm below
348    // only ever fires when `target_name` is already the qualified
349    // `List.Item` spelling. A bare reference to a list item's own name
350    // (`flags Palette = double, other` then `var alias = double`) needs
351    // the same suffix scan `lookup_variable` itself uses
352    // (`lookup_list_item_bare`, priority 3, strictly ahead of the knot
353    // lookup at priority 6) — without it this guard silently disagreed
354    // with `fold_path_ref` in exactly the same file, no cross-module
355    // privacy gate to save it: `alias` typed `Ty::Fn` from the knot
356    // `double` while lowering bound it to the list item, producing a
357    // bogus `E063` the moment a caller's declared parameter type didn't
358    // happen to admit `fn`. `native_bare_name_shadowed_by_a_same_named_list_item_is_not_typed_as_a_fn_value`
359    // (`crates/brink-compiler/tests/driver.rs`) is the regression test;
360    // reverting the `lookup_list_item_bare` call below reproduces the
361    // bogus `E063` it guards against.
362    if matches!(value, Expr::Path(_))
363        && (index.by_name.get(target_name.as_str()).is_some_and(|ids| {
364            ids.iter().any(|id| {
365                index.symbols.get(id).is_some_and(|i| {
366                    matches!(
367                        i.kind,
368                        SymbolKind::Variable | SymbolKind::Constant | SymbolKind::ListItem
369                    )
370                })
371            })
372        }) || !matches!(
373            lookup_list_item_bare(index, target_name.as_str()),
374            BareItemResult::NotFound
375        ))
376    {
377        return None;
378    }
379    // Signature typing of a global `#fn` initializer has no per-file import
380    // context (no enclosing body), so it looks the target up flatly (M-2d
381    // import scoping — issue #790 — is inert here: a single-candidate knot
382    // hits `lookup_by_name`'s fast path and resolves identically; only the
383    // new multi-declared-module homonym case, not reachable from a typed
384    // `#fn` target today, would differ).
385    let target_def = lookup_by_name(
386        index,
387        &crate::resolve::ImportScope::default(),
388        &target_name,
389        &[SymbolKind::Knot],
390    )?;
391    let target_info = index.symbols.get(&target_def)?;
392    if target_info.detail.as_deref() != Some("function") {
393        return None;
394    }
395    // `signature()` is only ever handed a *narrowed* `files` slice by
396    // `brink-db`'s per-def `signature_query` (FG-2, single declaring file
397    // only — never a whole-project scan, by design). A cross-file `#fn`
398    // target's HIR simply isn't in that slice; calling `signature()`
399    // anyway would silently return a Sig with empty `param_annotations`
400    // (indistinguishable from "declares zero params"), fabricating a wrong
401    // arity instead of the honest "can't determine" `None`. The
402    // whole-project caller (`infer::collect_globals`, which always passes
403    // every file) never hits this guard.
404    if !files.iter().any(|&(id, _)| id == target_info.file) {
405        return None;
406    }
407    // Single-level recursion into the *target's* declaration-derived
408    // signature only (never its body) — `#fn` targets are always knots, so
409    // this never re-enters the `Variable`/`Constant` arms that call
410    // `declared_fn_type` in the first place.
411    let target_sig = signature(target_def, index, files, manifest)?;
412    let remaining: Vec<Ty> = target_sig
413        .param_annotations
414        .iter()
415        .skip(bound_args)
416        .map(|a| a.clone().unwrap_or(Ty::Unknown))
417        .collect();
418    let ret = target_sig.return_annotation.clone().unwrap_or(Ty::Unknown);
419    // The declaration-derived effect row (issue #1680,
420    // `docs/effects-spec.md` §5): this cell's initializer *is* a `#fn`
421    // creation site, and `target_def` is the target it names — syntactic
422    // evidence, resolved above by name lookup, never an inferred row
423    // (§6.1a). This is the row `collect_globals` inserts once and
424    // `BodyCtx::globals` only ever reads (`infer/mod.rs`'s
425    // `collect_globals`, `infer/body.rs`'s `ty_of_def`) — a later
426    // `~ cell = #fn(other)` write is folded into the effect walk's write
427    // set, never back into this cell's type, so the row stays fixed at
428    // this declaration's target and under-approximates any cell reassigned
429    // to a different fn value (`docs/effects-spec.md` §6.1c, filed against
430    // #1753).
431    Some(Ty::Fn(
432        remaining,
433        Box::new(ret),
434        crate::infer::FnRow::of_target(target_def),
435    ))
436}
437
438/// Compute the signature stub for one definition.
439///
440/// Reads the declaration only: the indexed `SymbolInfo` plus the declaring
441/// file's HIR for the initializer expression and the `#@local` bit. Returns
442/// `None` for an unknown definition id.
443#[must_use]
444#[expect(
445    clippy::too_many_lines,
446    reason = "each per-kind annotation-resolution branch below (Variable/Constant/ \
447              Knot/Stitch) threads the same TypeNames bundle (lists/structs/handles, \
448              TM-4b + T1d-2) through its own param/return/value-type resolution — \
449              a wide but flat shape, not a new structural concern worth splitting out"
450)]
451pub fn signature(
452    def: DefinitionId,
453    index: &SymbolIndex,
454    files: &[(FileId, &HirFile)],
455    manifest: Option<&HostManifest>,
456) -> Option<Arc<Sig>> {
457    let info = index.symbols.get(&def)?;
458    let hir = files
459        .iter()
460        .find(|&&(id, _)| id == info.file)
461        .map(|&(_, hir)| hir);
462
463    let mut value_type = None;
464    let mut value_ty = None;
465    let mut is_local = false;
466    let mut param_annotations = Vec::new();
467    let mut return_annotation = None;
468    if let Some(hir) = hir {
469        // `List<L>`/declared-struct annotations resolve nominally against
470        // every declared `LIST`/`STRUCT` in the project (spec §2/§3, TM-4b
471        // §6) — computed lazily, only when this def actually has a
472        // knot/stitch/var to annotate below. T1d-2b (issue #774): the
473        // registered `HostManifest` now reaches `signature()` too (threaded
474        // by `brink-db`'s per-def `signature_query`, the same coarse
475        // project-wide dependency shape `per_file_diagnostics_query` already
476        // reads `host_manifest` at), so `Handle<K>` annotations resolve
477        // against the manifest's declared kind vocabulary here exactly like
478        // `List<L>`/`STRUCT` resolve against the project's declared names —
479        // `None` (no manifest registered) still degrades to an empty
480        // handle-kind set, same "unresolved -> silent" contract every other
481        // unrecognized name already gets.
482        let names = || crate::annotations::TypeNames::new(index, manifest);
483        match info.kind {
484            SymbolKind::Variable => {
485                if let Some(v) = hir.variables.iter().find(|v| v.name.text == info.name) {
486                    is_local = v.is_local;
487                    // TM-2: annotation wins over the literal-inferred type.
488                    value_type = value_type_with_annotation_override(
489                        infer_literal_type(&v.value, index),
490                        v.annotation.as_ref(),
491                        &names(),
492                    );
493                    // Issue #1540: the full-fidelity type every typed
494                    // consumer reads — `Ty::Array`/`Map`/`Struct`/`Fn`/
495                    // `Option`/`Range` have no `InferredType` home, so they
496                    // ride here. See `Sig::value_ty`.
497                    value_ty = declared_value_ty(
498                        &v.value,
499                        v.annotation.as_ref(),
500                        index,
501                        files,
502                        &names(),
503                        manifest,
504                        hir.native,
505                    );
506                }
507            }
508            SymbolKind::Constant => {
509                if let Some(c) = hir.constants.iter().find(|c| c.name.text == info.name) {
510                    // TM-2: annotation wins over the literal-inferred type
511                    // (same firewall rule as VAR — see the `Variable` arm).
512                    value_type = value_type_with_annotation_override(
513                        infer_literal_type(&c.value, index),
514                        c.annotation.as_ref(),
515                        &names(),
516                    );
517                    // Issue #1540 — see the `Variable` arm.
518                    value_ty = declared_value_ty(
519                        &c.value,
520                        c.annotation.as_ref(),
521                        index,
522                        files,
523                        &names(),
524                        manifest,
525                        hir.native,
526                    );
527                }
528            }
529            SymbolKind::Knot => {
530                if let Some(k) = hir.knots.iter().find(|k| k.name.text == info.name) {
531                    is_local = k.is_local;
532                    let names = names();
533                    param_annotations = k
534                        .params
535                        .iter()
536                        .map(|p| {
537                            p.annotation
538                                .as_ref()
539                                .and_then(|a| resolve_annotation(a, &names))
540                        })
541                        .collect();
542                    return_annotation = k
543                        .return_type
544                        .as_ref()
545                        .and_then(|rt| resolve_annotation(rt, &names));
546                }
547            }
548            SymbolKind::Stitch => {
549                // Indexed stitch names are qualified (`knot.stitch`); HIR
550                // nests stitches under their knot by bare name. A top-level
551                // stitch is promoted to knot status during HIR lowering.
552                if let Some((knot_name, stitch_name)) = info.name.split_once('.') {
553                    if let Some(s) = hir
554                        .knots
555                        .iter()
556                        .find(|k| k.name.text == knot_name)
557                        .and_then(|k| k.stitches.iter().find(|s| s.name.text == stitch_name))
558                    {
559                        is_local = s.is_local;
560                        let names = names();
561                        param_annotations = s
562                            .params
563                            .iter()
564                            .map(|p| {
565                                p.annotation
566                                    .as_ref()
567                                    .and_then(|a| resolve_annotation(a, &names))
568                            })
569                            .collect();
570                        return_annotation = s
571                            .return_type
572                            .as_ref()
573                            .and_then(|rt| resolve_annotation(rt, &names));
574                    }
575                } else if let Some(k) = hir.knots.iter().find(|k| k.name.text == info.name) {
576                    is_local = k.is_local;
577                    let names = names();
578                    param_annotations = k
579                        .params
580                        .iter()
581                        .map(|p| {
582                            p.annotation
583                                .as_ref()
584                                .and_then(|a| resolve_annotation(a, &names))
585                        })
586                        .collect();
587                    return_annotation = k
588                        .return_type
589                        .as_ref()
590                        .and_then(|rt| resolve_annotation(rt, &names));
591                }
592            }
593            _ => {}
594        }
595    }
596
597    Some(Arc::new(Sig {
598        name: info.name.clone(),
599        kind: info.kind,
600        params: info.params.clone(),
601        value_type,
602        value_ty,
603        is_local,
604        param_annotations,
605        return_annotation,
606    }))
607}
608
609/// The per-file locals path [`signature`] itself cannot take (issue #530).
610///
611/// A local (`Param`/`Temp`) `DefinitionId` is a content hash of
612/// `(scope, name, kind)` alone (`manifest::local_definition_id`) — it has
613/// no file component, unlike a declaration's id (recoverable from the
614/// project-wide index's `SymbolInfo.file`). So `signature`'s own
615/// `index.symbols.get(&def)` lookup can never find one once the caller
616/// passes the decls-only [`resolution_index_query`](../../brink_db)
617/// projection ([`resolution_index_query`]'s own doc: locals are dropped
618/// entirely, issue #517) — and widening that lookup to the full,
619/// range-carrying index would reintroduce exactly the whole-project
620/// invalidation #517's cutoff exists to kill. A local's body lives in
621/// exactly one file (the same fact [`crate::resolve::lookup_local_in_scope`]
622/// already leans on), so a caller that already knows which file declares
623/// the local — hover, go-to-def, resolved from a reference inside that
624/// same file — can hand this function that file's own `manifest` directly,
625/// keeping the lookup a narrow per-file scan instead of a project-wide one.
626///
627/// Declaration-derived only, matching [`signature`]'s own contract: a
628/// `Param`'s or `~ temp`'s TM-2 (docs/typed-mode-spec.md §3) `: type`
629/// annotation, if any — no body inference (that's `infer_body`'s job, read
630/// separately by a caller that wants an inferred fallback). `is_local` is
631/// always `false` — the `#@local` flow-private directive only exists on
632/// knot/stitch/VAR declarations, never on a param or temp. `params` and
633/// `param_annotations` stay empty and `return_annotation` stays `None` —
634/// a local isn't callable, so those `Sig` fields (knot/stitch-only) don't
635/// apply. Returns `None` when `def` doesn't match any local declared in
636/// `manifest`.
637#[must_use]
638pub fn local_signature(
639    def: DefinitionId,
640    manifest: &brink_ir::SymbolManifest,
641    index: &SymbolIndex,
642    host: Option<&HostManifest>,
643) -> Option<Arc<Sig>> {
644    let local = manifest
645        .locals
646        .iter()
647        .find(|l| crate::manifest::local_definition_id(&l.scope, &l.name, l.kind) == def)?;
648
649    let names = crate::annotations::TypeNames::new(index, host);
650    let value_ty = local
651        .annotation
652        .as_ref()
653        .and_then(|a| resolve_annotation(a, &names));
654    let value_type = value_ty.as_ref().and_then(ty_to_inferred_type);
655
656    Some(Arc::new(Sig {
657        name: local.name.clone(),
658        kind: local.kind,
659        params: Vec::new(),
660        value_type,
661        value_ty,
662        is_local: false,
663        param_annotations: Vec::new(),
664        return_annotation: None,
665    }))
666}