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}