Skip to main content

brink_analyzer/
effects_assertions.rs

1//! T2-2 (docs/effects-spec.md §10, issue #861): compile-time check of every
2//! `#@effects(…)` assertion against its definition's inferred effect row —
3//! the *only* diagnostic the T2 sitting-2 ruling (2026-07-14) assigns this
4//! surface, **exceedance** (`E103`): the inferred row is not covered by
5//! (⊄) the declared upper bound. Per that ruling there is no drift policy —
6//! an inferred row that is *narrower* than its bound is silent; nothing
7//! else warns.
8//!
9//! One other error class lives here: a clause naming an identifier that
10//! isn't a declared global cell (`reads`/`writes`) or a declared `EXTERNAL`
11//! (`calls`) anywhere in the project (`E102`). This is ordinary directive
12//! well-formedness, not "drift" — the assertion can't even be built into a
13//! row without it. The grammar-level `E100`/`E101` (missing argument,
14//! malformed clause) are minted by `brink-ir`'s directive recognizer before
15//! this module ever runs.
16//!
17//! Callers only run this under `dialect = brink`, mirroring TM-2's
18//! annotation-content precedent (`per_file_diagnostics`'s doc): under
19//! `strict-ink` the directive is already rejected whole by `dialect_gate`
20//! (`E051`), so critiquing its declared names would be noise.
21
22use std::collections::BTreeMap;
23use std::collections::BTreeSet;
24
25use brink_format::DefinitionId;
26use brink_ir::{
27    Diagnostic, DiagnosticCode, EffectsAssertion, FileId, HirFile, SymbolIndex, SymbolKind,
28};
29use rowan::TextRange;
30
31use crate::infer::EffectRow;
32use crate::resolve::{ImportScope, lookup_by_name};
33
34/// The index + import scope every name lookup in this module needs together
35/// (issue #881) — bundled so `check_one` doesn't carry them as two separate
36/// parameters (`clippy::too_many_arguments`).
37struct Ctx<'a> {
38    index: &'a SymbolIndex,
39    scope: &'a ImportScope,
40}
41
42/// Check every knot/stitch's `#@effects(…)` assertion in `hir` against
43/// `rows` — that def's inferred [`EffectRow`], however the caller computed
44/// it: the whole-project pure [`crate::effects_project`] for the analyzer's
45/// monolithic path, or, for the salsa-memoized production path, a small map
46/// built from individual per-def `effects(def)` queries (only for the defs
47/// that actually carry an assertion, preserving the advisory/lazy
48/// invariant — an unannotated project never triggers effect inference at
49/// all).
50///
51/// A def whose own id can't be resolved, or whose row is missing from
52/// `rows`, produces no diagnostic here — both are the caller's contract to
53/// uphold (every assertion-carrying def gets an entry), not a case this
54/// function can distinguish from "not computed yet".
55///
56/// `scope` is `hir`'s own [`ImportScope`] (issue #881, the T2 follow-up to
57/// M-2d/#790): a `reads`/`writes`/`calls` clause name is resolved through the
58/// exact same import-scoped [`lookup_by_name`] the reference resolver uses,
59/// so a `#@effects` assertion in a file that imports one of several
60/// same-name cross-module cells binds to *that* importer's cell — never a
61/// flat first-inserted winner that could silently name a different module's
62/// definition than the one the body's own inferred row actually touches.
63#[must_use]
64pub fn check(
65    file: FileId,
66    hir: &HirFile,
67    index: &SymbolIndex,
68    scope: &ImportScope,
69    rows: &BTreeMap<DefinitionId, EffectRow>,
70) -> Vec<Diagnostic> {
71    let ctx = Ctx { index, scope };
72    let mut out = Vec::new();
73    for knot in &hir.knots {
74        let kind = knot.symbol_kind();
75        check_one(
76            file,
77            knot.effects_assertion.as_ref(),
78            kind,
79            &knot.name.text,
80            &ctx,
81            rows,
82            &mut out,
83        );
84        for stitch in &knot.stitches {
85            let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
86            check_one(
87                file,
88                stitch.effects_assertion.as_ref(),
89                SymbolKind::Stitch,
90                &qualified,
91                &ctx,
92                rows,
93                &mut out,
94            );
95        }
96    }
97    out
98}
99
100/// Every def carrying a `#@effects(…)` assertion in `hir`, paired with the
101/// [`DefinitionId`] the exceedance check needs its row for — the seam a
102/// salsa caller uses to fetch exactly those rows (and no others) via the
103/// per-def `effects(def)` query, keeping unannotated projects inference-free.
104#[must_use]
105pub fn assertion_defs(hir: &HirFile, index: &SymbolIndex, file: FileId) -> Vec<DefinitionId> {
106    let mut out = Vec::new();
107    for knot in &hir.knots {
108        let kind = knot.symbol_kind();
109        if knot.effects_assertion.is_some()
110            && let Some(id) = find_def_id(index, file, kind, &knot.name.text)
111        {
112            out.push(id);
113        }
114        for stitch in &knot.stitches {
115            if stitch.effects_assertion.is_some() {
116                let qualified = format!("{}.{}", knot.name.text, stitch.name.text);
117                if let Some(id) = find_def_id(index, file, SymbolKind::Stitch, &qualified) {
118                    out.push(id);
119                }
120            }
121        }
122    }
123    out
124}
125
126fn check_one(
127    file: FileId,
128    assertion: Option<&EffectsAssertion>,
129    kind: SymbolKind,
130    name: &str,
131    ctx: &Ctx<'_>,
132    rows: &BTreeMap<DefinitionId, EffectRow>,
133    out: &mut Vec<Diagnostic>,
134) {
135    let Some(assertion) = assertion else {
136        return;
137    };
138    let Some(def_id) = find_def_id(ctx.index, file, kind, name) else {
139        return;
140    };
141    let Some(inferred) = rows.get(&def_id) else {
142        return;
143    };
144
145    // ── NS-A2 (issue #1108): the output/fault dimension assertions —
146    // `silent` (no emits) and `total` (no faults), each exceedance-only
147    // with its own code. Opaque rows are unbounded on every dimension
148    // (spec §3), so they exceed any concrete assertion — and so does a row
149    // still carrying a §6.1 row variable (issue #1680), which is why every
150    // check here reads `is_pessimal()` rather than the intrinsic `opaque`
151    // bit: a higher-order definition's own effects are not bounded until a
152    // caller instantiates its hole.
153    if assertion.silent && (inferred.emits || inferred.is_pessimal()) {
154        out.push(Diagnostic {
155            file,
156            range: assertion.range,
157            code: DiagnosticCode::E108,
158            message: if inferred.is_pessimal() {
159                "inferred effects are unbounded (a call through a function value, or an                  unresolved callee) — the `silent` assertion cannot cover this definition"
160                    .to_string()
161            } else {
162                "inferred effects exceed the `silent` assertion: the definition can produce                  content (a content line, or a transitive call to an emitter)"
163                    .to_string()
164            },
165        });
166    }
167    if assertion.total && (inferred.faults || inferred.is_pessimal()) {
168        out.push(Diagnostic {
169            file,
170            range: assertion.range,
171            code: DiagnosticCode::E109,
172            message: if inferred.is_pessimal() {
173                "inferred effects are unbounded (a call through a function value, or an                  unresolved callee) — the `total` assertion cannot cover this definition"
174                    .to_string()
175            } else {
176                "inferred effects exceed the `total` assertion: the definition can raise a                  turn-terminating fault"
177                    .to_string()
178            },
179        });
180    }
181
182    // ── The state-row bound (`pure`, or one or more reads/writes/calls
183    // clauses) — the pre-NS-A2 `E102`/`E103` surface, unchanged. An
184    // assertion carrying only `silent`/`total` leaves the state row
185    // unbounded, so there is nothing further to check.
186    if !assertion.pure
187        && assertion.reads.is_empty()
188        && assertion.writes.is_empty()
189        && assertion.calls.is_empty()
190    {
191        return;
192    }
193
194    let mut well_formed = true;
195    let mut declared_reads = BTreeSet::new();
196    for n in &assertion.reads {
197        if let Some(id) = resolve_cell(ctx, n) {
198            declared_reads.insert(id);
199        } else {
200            out.push(unknown_name_diagnostic(file, assertion.range, n));
201            well_formed = false;
202        }
203    }
204    let mut declared_writes = BTreeSet::new();
205    for n in &assertion.writes {
206        if let Some(id) = resolve_cell(ctx, n) {
207            declared_writes.insert(id);
208        } else {
209            out.push(unknown_name_diagnostic(file, assertion.range, n));
210            well_formed = false;
211        }
212    }
213    let mut declared_calls = BTreeSet::new();
214    for n in &assertion.calls {
215        if external_declared(ctx, n) {
216            declared_calls.insert(n.clone());
217        } else {
218            out.push(unknown_name_diagnostic(file, assertion.range, n));
219            well_formed = false;
220        }
221    }
222    if !well_formed {
223        // Malformed names already diagnosed (E102) — skip the exceedance
224        // check to avoid a confusing second diagnostic over an assertion
225        // that can't even be resolved into a row yet.
226        return;
227    }
228
229    // The state bound never constrains the output/fault dimensions (those
230    // have their own assertion args above), so the declared row mirrors the
231    // inferred row on emits/tags/faults — `covers` then compares exactly
232    // the reads/writes/calls sets plus the opaque top.
233    let declared_row = EffectRow {
234        reads: declared_reads,
235        writes: declared_writes,
236        calls: declared_calls,
237        opaque: false,
238        emits: inferred.emits,
239        tags: inferred.tags,
240        faults: inferred.faults,
241        // Mirrored like the other output/fault dimensions — the refined
242        // bit (F29) is not part of `covers` semantics and never
243        // assertable.
244        faults_refined: inferred.faults_refined,
245        // An author-written assertion is always a ground row — §6.1 row
246        // variables are checker-minted and never spellable (spec §14.5/§11:
247        // rows are never author-written). An inferred row that still holds
248        // one is pessimal, so `covers` rejects it here exactly as it rejects
249        // an opaque one.
250        holes: BTreeSet::new(),
251    };
252    if !declared_row.covers(inferred) {
253        out.push(Diagnostic {
254            file,
255            range: assertion.range,
256            code: DiagnosticCode::E103,
257            message: exceedance_message(&declared_row, inferred, ctx.index),
258        });
259    }
260}
261
262/// This definition's own [`DefinitionId`] — the merged index's `by_name`
263/// reverse lookup, disambiguated by file + [`SymbolKind`] (mirrors
264/// `infer::collect_defs`'s `def_of` construction, one name at a time
265/// instead of building the whole project's map up front — this is only
266/// ever called for the handful of defs that actually carry an assertion).
267fn find_def_id(
268    index: &SymbolIndex,
269    file: FileId,
270    kind: SymbolKind,
271    name: &str,
272) -> Option<DefinitionId> {
273    index.by_name.get(name)?.iter().copied().find(|id| {
274        index
275            .symbols
276            .get(id)
277            .is_some_and(|info| info.file == file && info.kind == kind)
278    })
279}
280
281/// Resolve a `reads`/`writes` clause name to a global `VAR`/`CONST`
282/// [`DefinitionId`], through the same import-scoped [`lookup_by_name`] the
283/// reference resolver uses (issue #881 — the T2 follow-up to M-2d/#790:
284/// "twin semantic checks share one helper, never re-derive", #811's
285/// lesson). Before this fix the clause was resolved by an independent
286/// flat `by_name` scan picking the smallest same-named `DefinitionId`,
287/// which could silently disagree with which module's cell the assertion's
288/// own def actually reads/writes whenever two declared modules publicly
289/// define the same name — `lookup_by_name` picks the referrer's own-module
290/// candidate first, then an imported one, exactly like every other
291/// reference in this file resolves.
292fn resolve_cell(ctx: &Ctx<'_>, name: &str) -> Option<DefinitionId> {
293    let resolved = lookup_by_name(
294        ctx.index,
295        ctx.scope,
296        name,
297        &[SymbolKind::Variable, SymbolKind::Constant],
298    );
299    if resolved.is_some() {
300        return resolved;
301    }
302    // NS-A6 (issue #1112, `docs/stdlib-spec.md` §7): `rng` names the
303    // compiler-owned `std::rand` RNG state cell — the cell every draw
304    // verb writes — so a draw-bearing def can carry a covering bound
305    // (`@[effects(writes rng)]`). A user-declared `VAR`/`CONST` named
306    // `rng` shadows this (the lookup above wins), consistent with the
307    // stdlib-name shadowing rule everywhere else.
308    if name == "rng" {
309        return Some(DefinitionId::RNG_CELL);
310    }
311    None
312}
313
314/// Whether `name` is a declared `EXTERNAL` visible to this file's import
315/// scope (issue #881, same fix as [`resolve_cell`]). `calls` clauses match
316/// [`EffectRow::calls`] by raw name (T2-1 collects external call atoms the
317/// same way), so only existence of an in-scope candidate is needed, not its
318/// id.
319fn external_declared(ctx: &Ctx<'_>, name: &str) -> bool {
320    lookup_by_name(ctx.index, ctx.scope, name, &[SymbolKind::External]).is_some()
321}
322
323fn unknown_name_diagnostic(file: FileId, range: TextRange, name: &str) -> Diagnostic {
324    Diagnostic {
325        file,
326        range,
327        code: DiagnosticCode::E102,
328        message: format!(
329            "the effects assertion names `{name}`, which isn't a declared global VAR/CONST or EXTERNAL anywhere in the project"
330        ),
331    }
332}
333
334/// The author-facing name of one effect-row atom.
335///
336/// **The single authority on what an effect atom is called.** Two surfaces
337/// print these — the IDE's hover row (`brink_ide::effects::EffectRowView`)
338/// and the `E103` exceedance message below — and they must agree, because
339/// an author reads one and then goes looking for the other.
340///
341/// The compiler-owned RNG cell has no symbol-index entry, so a plain index
342/// lookup falls through to the id's debug form. That shipped: hover showed
343/// `writes: GlobalVar(0x5eed0000d1ce)`, a raw internal handle, for any
344/// function that calls `RANDOM`. It is named the way the assertion surface
345/// spells it (`@[effects(writes rng)]`), so the name an author reads is the
346/// name they would write.
347#[must_use]
348pub fn effect_atom_name(id: DefinitionId, index: &SymbolIndex) -> String {
349    if id == DefinitionId::RNG_CELL {
350        return "rng".to_string();
351    }
352    index
353        .symbols
354        .get(&id)
355        .map_or_else(|| format!("{id:?}"), |info| info.name.clone())
356}
357
358/// Build the `E103` exceedance message: an opaque inferred row (a call
359/// through a function value, or an unresolved callee — spec §3) can never
360/// be bounded by a concrete assertion, so it gets its own explanatory
361/// message; otherwise the message lists every atom the assertion under-
362/// declares.
363fn exceedance_message(declared: &EffectRow, inferred: &EffectRow, index: &SymbolIndex) -> String {
364    if inferred.is_pessimal() {
365        return "inferred effects are unbounded (a call through a function value, or an \
366                 unresolved callee) — no effects assertion can cover this definition"
367            .to_string();
368    }
369    let name_of = |id: &DefinitionId| {
370        let name = effect_atom_name(*id, index);
371        // The name comes from the shared authority above; the gloss is
372        // diagnostic prose, and belongs only here — an author who never
373        // wrote `rng` needs to be told why it is in their row. Hover has no
374        // room for it and does not need it.
375        if *id == DefinitionId::RNG_CELL {
376            return format!("{name} (the std::rand RNG state cell)");
377        }
378        name
379    };
380    let mut parts = Vec::new();
381    let extra_reads: Vec<String> = inferred
382        .reads
383        .difference(&declared.reads)
384        .map(name_of)
385        .collect();
386    if !extra_reads.is_empty() {
387        parts.push(format!("reads {}", extra_reads.join(", ")));
388    }
389    let extra_writes: Vec<String> = inferred
390        .writes
391        .difference(&declared.writes)
392        .map(name_of)
393        .collect();
394    if !extra_writes.is_empty() {
395        parts.push(format!("writes {}", extra_writes.join(", ")));
396    }
397    let extra_calls: Vec<String> = inferred
398        .calls
399        .difference(&declared.calls)
400        .cloned()
401        .collect();
402    if !extra_calls.is_empty() {
403        parts.push(format!("calls {}", extra_calls.join(", ")));
404    }
405    format!(
406        "inferred effects exceed the effects assertion's declared bound: {}",
407        parts.join("; ")
408    )
409}