brink-analyzer 0.0.16

Cross-file semantic analysis for inkle's ink narrative scripting language
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
//! Creation-site checks for function-value references — the ink/brink
//! `#fn(name, args…)` literal (docs/t1c-spec.md §2/§8, issue #699) **and**
//! the native bare-name spelling (`docs/t1c-spec.md` §2a, issue #1862).
//! Two entry points, one shared obligation set:
//!
//! - [`check`] — the `#fn(name, args…)` literal, available on both surfaces
//!   under `dialect = brink`.
//! - [`check_native_bare_refs`] — the native-only bare-name spelling
//!   (`handler(scene)`, no sigil), unconditional for every native file
//!   regardless of `dialect`; see that function's own doc for why.
//!
//! "Every static obligation lands at this one marked site" — the creation
//! site is where the target name becomes a value, where `ref` params bind,
//! and (T2) where the effect row freezes. Three diagnostics enforce the
//! ruled discipline for [`check`]'s `#fn(…)` literal:
//!
//! - **E079** — the target must resolve to a statically-named *function
//!   definition* (`=== function name ===`). A variable, list, external,
//!   label, non-function knot/stitch, or a builtin/stdlib intrinsic name is
//!   not a definition a fn token can be taken of. A target that failed
//!   resolution entirely already carries resolution's own `E025` — not
//!   double-reported here — *except* the builtin/stdlib names, which
//!   `resolve::resolve_function` deliberately skips without a diagnostic
//!   (they're valid *calls*), so this pass is where those become errors as
//!   `#fn` targets.
//! - **E080** — every `ref` param of the target must be bound in the
//!   creation-site prefix, and each `ref`-position argument must be an
//!   lvalue naming a durable cell: a global `VAR` (`#@local` flow-local
//!   VARs included — same `SymbolKind::Variable` at this layer). `temp`s
//!   and params die with the frame (value-model §11), `CONST` is not a
//!   mutable cell, and rvalues/field projections are not cells at all —
//!   "a durable cell, never a heap location".
//! - **E081** — the bound args are a *prefix* of the declared param row;
//!   binding more than the target declares is a compile error.
//!
//! [`check`] runs only under `dialect = brink` (`per_file_diagnostics` gates
//! the call): under `strict-ink` the whole literal is already rejected as
//! extension syntax (E051), and content diagnostics on rejected syntax are
//! noise (the TM-2 annotation-content precedent, ruling 2026-07-13).
//! Dialect-level, not type-policy-level: these obligations hold under
//! `types = gradual` too.
//!
//! [`check_native_bare_refs`] is gated differently — on `hir.native`, not
//! `dialect` — because the bare-name spelling is a property of the *surface*
//! (native vs. ink), not a dialect extension: a `.brink` file compiled under
//! the default `strict-ink` dialect still needs this check. Of the three
//! obligations above, only E080 survives the bare-name translation (see
//! that function's own doc for why E079/E081 are unreachable there).

use std::collections::BTreeMap;

use brink_format::DefinitionId;
use brink_ir::hir::visit::{self, HirVisitor};
use brink_ir::{
    Diagnostic, DiagnosticCode, Expr, FileId, FnLiteral, HirFile, ResolutionMap, SymbolIndex,
    SymbolKind,
};
use rowan::TextRange;

/// `(start, end)` key for range-indexed lookups (`TextRange` has no `Ord`).
fn range_key(range: TextRange) -> (u32, u32) {
    (range.start().into(), range.end().into())
}

/// Walk one file's HIR and check every `#fn` creation site.
///
/// `file_resolutions` is this file's own slice of the resolution map (the
/// same shape [`crate::per_file_diagnostics`] hands `dialect_gate::check`);
/// `index` supplies the resolved target's kind/params and each
/// `ref`-argument's resolved kind.
#[must_use]
pub fn check(
    files: &[(FileId, &HirFile)],
    file_resolutions: &ResolutionMap,
    index: &SymbolIndex,
) -> Vec<Diagnostic> {
    let mut out = Vec::new();
    for &(file, hir) in files {
        let by_range: BTreeMap<(u32, u32), DefinitionId> = file_resolutions
            .iter()
            .filter(|r| r.file == file)
            .map(|r| (range_key(r.range), r.target))
            .collect();
        let mut v = FnValueVisitor {
            file,
            by_range: &by_range,
            index,
            diagnostics: &mut out,
        };
        visit::visit(hir, &mut v);
    }
    out
}

/// The **native** half of the same creation-site discipline (RULED
/// 2026-08-01, `docs/t1c-spec.md` §2a, issue #1862): on the `.brink`
/// surface a statically-named function in expression position *is* a fn
/// value, spelled as the bare name (`handler(scene)`) with no sigil.
///
/// That spelling can bind **no** arguments — the `#fn(f, a)` partial-
/// application form deliberately has no native spelling — so exactly one of
/// [`check`]'s three obligations survives the translation, and it survives
/// as an absolute: a target with any `ref` parameter can never be
/// referenced this way, because "all ref params bind in the creation-site
/// prefix" (E080) and the prefix here is always empty. E081 (over-binding)
/// and E079 (target is not a function) are both unreachable — there are no
/// bound args to over-bind, and a bare name that resolves to something
/// other than a function is simply not a fn value at all (it stays a
/// variable read / visit count, `lir::lower::expr::lower_path`).
///
/// Runs for every native file regardless of the (ink-only) `dialect` axis
/// — the same wiring rule the construction-literal checks use, and for the
/// same reason: a `.brink` file compiled under the default `strict-ink`
/// dialect must still get these errors.
#[must_use]
pub fn check_native_bare_refs(
    files: &[(FileId, &HirFile)],
    file_resolutions: &ResolutionMap,
    index: &SymbolIndex,
) -> Vec<Diagnostic> {
    let mut out = Vec::new();
    for &(file, hir) in files {
        if !hir.native {
            continue;
        }
        let by_range: BTreeMap<(u32, u32), DefinitionId> = file_resolutions
            .iter()
            .filter(|r| r.file == file)
            .map(|r| (range_key(r.range), r.target))
            .collect();
        let mut v = NativeFnRefVisitor {
            file,
            by_range: &by_range,
            index,
            diagnostics: &mut out,
        };
        // `visit::visit` alone only walks the block tree — it never
        // descends into a file-level `VAR`/`CONST` initializer expression
        // (issue #1571's own doc on `visit_with_decl_initializers`). A
        // native declaration-initializer position (`var f = heal;`) is
        // exactly as much a bare-name fn-value creation site as an
        // expression inside a knot body, so this walk must use the
        // initializer-inclusive entry point or an unbound `ref` param on a
        // decl-initializer target silently gets no E080.
        visit::visit_with_decl_initializers(hir, &mut v);
    }
    out
}

struct NativeFnRefVisitor<'a> {
    file: FileId,
    by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
    index: &'a SymbolIndex,
    diagnostics: &'a mut Vec<Diagnostic>,
}

impl HirVisitor for NativeFnRefVisitor<'_> {
    fn visit_exprs(&self) -> bool {
        true
    }

    fn enter_expr(&mut self, expr: &Expr) {
        // Only a *bare* path is a candidate: a call's callee path is not
        // walked as an `Expr::Path` at all (`hir::visit::walk_expr`'s
        // `Expr::Call` arm descends into the arguments only), so
        // `scene()` never reaches here — which is exactly what makes
        // reference-vs-call unambiguous on this surface.
        let Expr::Path(path) = expr else { return };
        let Some(&def) = self.by_range.get(&range_key(path.range)) else {
            return;
        };
        let Some(info) = self.index.symbols.get(&def) else {
            return;
        };
        if !info.is_function_definition() {
            return;
        }
        let target_name = path
            .segments
            .iter()
            .map(|s| s.text.as_str())
            .collect::<Vec<_>>()
            .join("::");
        for param in &info.params {
            if !param.is_ref {
                continue;
            }
            self.diagnostics.push(Diagnostic {
                file: self.file,
                range: path.range,
                message: format!(
                    "ref parameter `{}` of `{target_name}` must be bound at creation, but a \
                     bare-name fn value binds no arguments — the binding form has no native \
                     spelling (docs/t1c-spec.md §2a)",
                    param.name,
                ),
                code: DiagnosticCode::E080,
            });
        }
    }
}

struct FnValueVisitor<'a> {
    file: FileId,
    by_range: &'a BTreeMap<(u32, u32), DefinitionId>,
    index: &'a SymbolIndex,
    diagnostics: &'a mut Vec<Diagnostic>,
}

impl HirVisitor for FnValueVisitor<'_> {
    fn visit_exprs(&self) -> bool {
        true
    }

    fn enter_expr(&mut self, expr: &Expr) {
        if let Expr::FnLiteral(fl) = expr {
            self.check_fn_literal(fl);
        }
    }
}

impl FnValueVisitor<'_> {
    fn push(&mut self, range: TextRange, message: String, code: DiagnosticCode) {
        self.diagnostics.push(Diagnostic {
            file: self.file,
            range,
            message,
            code,
        });
    }

    fn check_fn_literal(&mut self, fl: &FnLiteral) {
        let target_name = fl
            .target
            .segments
            .iter()
            .map(|s| s.text.as_str())
            .collect::<Vec<_>>()
            .join(".");

        let Some(&def) = self.by_range.get(&range_key(fl.target.range)) else {
            // No resolution entry. For an ordinary unknown name, resolution
            // already reported `E025` — nothing to add. The builtin
            // (`RANDOM`, `INT`, …) and stdlib-intrinsic (`len`, `push`, …)
            // names are skipped silently by `resolve_function` because they
            // are valid *calls* — but they are not definitions, so as `#fn`
            // targets they are this pass's E079.
            let is_intrinsic = fl.target.segments.len() == 1
                && (crate::resolve::is_builtin_function(&target_name)
                    || crate::resolve::is_t1b_stdlib_name(&target_name));
            if is_intrinsic {
                self.push(
                    fl.target.range,
                    format!(
                        "`#fn` target `{target_name}` is a builtin, not a function \
                         definition — only a statically-named `=== function ===` can \
                         become a function value (docs/t1c-spec.md §2)"
                    ),
                    DiagnosticCode::E079,
                );
            }
            return;
        };

        let Some(info) = self.index.symbols.get(&def) else {
            // Not in the index. Under `brink-db`'s salsa pipeline the index
            // handed to per-file passes is the decls-only cutoff projection
            // (`resolution_index_query` strips `Param`/`Temp` symbols), so a
            // target that resolved to a *local* arrives here with a
            // resolution entry but no index info — classify by the id's own
            // `DefinitionTag` instead (locals are `LocalVar` by
            // construction, `SymbolKind::definition_tag`).
            if def.tag() == brink_format::DefinitionTag::LocalVar {
                self.push(
                    fl.target.range,
                    format!(
                        "`#fn` target `{target_name}` does not resolve to a \
                         statically-named function definition (resolved to a local \
                         temp/param) — declare the target as `=== function \
                         {target_name} ===` (docs/t1c-spec.md §2)"
                    ),
                    DiagnosticCode::E079,
                );
            }
            return;
        };

        if !info.is_function_definition() {
            self.push(
                fl.target.range,
                format!(
                    "`#fn` target `{target_name}` does not resolve to a statically-named \
                     function definition (resolved to a {}) — declare the target as \
                     `=== function {target_name} ===` (docs/t1c-spec.md §2)",
                    kind_label(info.kind),
                ),
                DiagnosticCode::E079,
            );
            return;
        }

        // E081 — over-binding: the bound args are a prefix, never longer
        // than the declared row.
        if fl.args.len() > info.params.len() {
            self.push(
                fl.ptr.text_range(),
                format!(
                    "`#fn` binds {} argument(s) but `{target_name}` declares only {} \
                     parameter(s) — bound args are a prefix of the declared row \
                     (docs/t1c-spec.md §2)",
                    fl.args.len(),
                    info.params.len(),
                ),
                DiagnosticCode::E081,
            );
        }

        // E080 — every `ref` param must be bound at creation, to a durable
        // cell.
        for (i, param) in info.params.iter().enumerate() {
            if !param.is_ref {
                continue;
            }
            match fl.args.get(i) {
                None => {
                    self.push(
                        fl.ptr.text_range(),
                        format!(
                            "ref parameter `{}` of `{target_name}` must be bound at \
                             creation — all ref params bind in the `#fn` prefix \
                             (docs/t1c-spec.md §2)",
                            param.name,
                        ),
                        DiagnosticCode::E080,
                    );
                }
                Some(arg) => self.check_ref_arg(fl, &target_name, &param.name, arg),
            }
        }
    }

    /// A `ref`-position argument must be an lvalue naming a durable cell:
    /// a single-segment path resolving to a global `VAR`. Everything else —
    /// rvalues, `temp`s/params, `CONST`s, dotted field projections — is
    /// E080 with a cause-specific message.
    fn check_ref_arg(&mut self, fl: &FnLiteral, target_name: &str, param_name: &str, arg: &Expr) {
        // T1e (docs/t1e-spec.md §2, tracking #828): an explicit `ref`-marked
        // argument (`#fn(heal, ref npc.hp)`) is validated by
        // `ref_projection::check`'s own `claim_ref_args`/`check_durable_root`
        // — the general path-projection machinery, which correctly accepts
        // field/index segments (unlike this function's bare-`Expr::Path`-only
        // check below, predating T1e and still governing the *unmarked*
        // ref-argument form, `#fn(heal, gold)`). Deferring here avoids a
        // double, disagreeing E080 report for the exact same argument.
        if matches!(arg, Expr::RefArg(_)) {
            return;
        }
        let reject = |cause: &str| {
            format!(
                "ref parameter `{param_name}` of `{target_name}` must capture a durable \
                 cell (a VAR, including `#@local` flow-locals) — {cause} \
                 (docs/t1c-spec.md §2)"
            )
        };
        let Expr::Path(p) = arg else {
            let msg = reject("this argument is not an lvalue");
            self.push(fl.ptr.text_range(), msg, DiagnosticCode::E080);
            return;
        };
        let Some(&arg_def) = self.by_range.get(&range_key(p.range)) else {
            // Unresolved reference — resolution's own E025 already covers it.
            return;
        };
        let Some(info) = self.index.symbols.get(&arg_def) else {
            // Resolved but absent from the decls-only index projection (see
            // `check_fn_literal`'s target arm): a local temp/param — never a
            // durable cell (value-model §11).
            if arg_def.tag() == brink_format::DefinitionTag::LocalVar {
                let msg = reject("a temp/param dies with its frame (value-model §11)");
                self.push(p.range, msg, DiagnosticCode::E080);
            }
            return;
        };
        // The TM-4b resolution fallback resolves a dotted `p.x` to its head
        // variable — that's a field projection (a heap location), never a
        // cell, regardless of what the head is.
        if p.segments.len() > 1 {
            let msg = reject("a field projection is a heap location, not a cell");
            self.push(p.range, msg, DiagnosticCode::E080);
            return;
        }
        match info.kind {
            SymbolKind::Variable => {}
            SymbolKind::Constant => {
                let msg = reject("a CONST is not a mutable cell");
                self.push(p.range, msg, DiagnosticCode::E080);
            }
            SymbolKind::Param | SymbolKind::Temp => {
                let msg = reject("a temp/param dies with its frame (value-model §11)");
                self.push(p.range, msg, DiagnosticCode::E080);
            }
            _ => {
                let msg = reject(&format!("a {} is not a cell", kind_label(info.kind)));
                self.push(p.range, msg, DiagnosticCode::E080);
            }
        }
    }
}

fn kind_label(kind: SymbolKind) -> &'static str {
    match kind {
        SymbolKind::Knot => "knot",
        SymbolKind::Stitch => "stitch",
        SymbolKind::Variable => "variable",
        SymbolKind::Constant => "constant",
        SymbolKind::List => "LIST",
        SymbolKind::ListItem => "list item",
        SymbolKind::External => "external function",
        SymbolKind::Label => "label",
        SymbolKind::Param => "parameter",
        SymbolKind::Temp => "temp",
        SymbolKind::Struct => "STRUCT",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use brink_ir::hir::lower;

    /// Parse → HIR lower → index → resolve — the real per-file pipeline
    /// shape `per_file_diagnostics` drives (same helper style as
    /// `strict::tests::build`).
    fn build(src: &str) -> (HirFile, SymbolIndex, ResolutionMap) {
        let parsed = brink_syntax::parse(src);
        let (hir, manifest, _diag) = lower(FileId(0), &parsed.tree());
        let (index, _diag) = crate::symbol_index(&[(FileId(0), &manifest)]);
        let (resolutions, _diag) =
            crate::resolve(FileId(0), &manifest, &index, &crate::ImportScope::default());
        (hir, (*index).clone(), (*resolutions).clone())
    }

    fn check_src(src: &str) -> Vec<Diagnostic> {
        let (hir, index, res) = build(src);
        check(&[(FileId(0), &hir)], &res, &index)
    }

    const HEAL: &str = "=== function heal(ref hp, amount) ===\n~ hp = hp + amount\n~ return hp\n\n";
    const PURE: &str = "=== function double(x) ===\n~ return x + x\n\n";

    // ── E079: target must be a function definition ───────────────────

    #[test]
    fn function_knot_target_is_clean() {
        let src = format!("{PURE}VAR v = 0\n=== main ===\n~ temp f = #fn(double, 1)\n-> DONE\n");
        let diags = check_src(&src);
        assert!(diags.is_empty(), "{diags:?}");
    }

    #[test]
    fn variable_target_is_e079() {
        let src = "VAR gold = 5\n=== main ===\n~ temp f = #fn(gold)\n-> DONE\n";
        let diags = check_src(src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E079);
        assert!(diags[0].message.contains("variable"), "{diags:?}");
    }

    #[test]
    fn non_function_knot_target_is_e079() {
        let src = "=== plain_knot ===\nHello.\n-> DONE\n=== main ===\n~ temp f = #fn(plain_knot)\n-> DONE\n";
        let diags = check_src(src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E079);
        assert!(diags[0].message.contains("knot"), "{diags:?}");
    }

    #[test]
    fn stdlib_intrinsic_target_is_e079() {
        // `len` never resolves (it's the builtin) — resolution skips it
        // silently as a *call*, so this pass is where it errors as a
        // target.
        let src = "=== main ===\n~ temp f = #fn(len)\n-> DONE\n";
        let diags = check_src(src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E079);
        assert!(diags[0].message.contains("builtin"), "{diags:?}");
    }

    #[test]
    fn uppercase_builtin_target_is_e079() {
        let src = "=== main ===\n~ temp f = #fn(RANDOM)\n-> DONE\n";
        let diags = check_src(src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E079);
    }

    #[test]
    fn unknown_target_is_left_to_resolutions_e025_not_double_reported() {
        let src = "=== main ===\n~ temp f = #fn(nowhere)\n-> DONE\n";
        let (hir, index, res) = build(src);
        let diags = check(&[(FileId(0), &hir)], &res, &index);
        assert!(diags.is_empty(), "E025 owns unknown names: {diags:?}");
    }

    #[test]
    fn external_target_is_e079() {
        let src = "EXTERNAL beep(x)\n=== main ===\n~ temp f = #fn(beep)\n-> DONE\n";
        let diags = check_src(src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E079);
        assert!(diags[0].message.contains("external"), "{diags:?}");
    }

    // ── E080: ref-binding discipline ──────────────────────────────────

    #[test]
    fn ref_param_bound_to_var_is_clean() {
        let src = format!(
            "{HEAL}VAR player_hp = 10\n=== main ===\n~ temp f = #fn(heal, player_hp)\n-> DONE\n"
        );
        let diags = check_src(&src);
        assert!(diags.is_empty(), "{diags:?}");
    }

    #[test]
    fn unbound_ref_param_is_e080() {
        let src = format!("{HEAL}=== main ===\n~ temp f = #fn(heal)\n-> DONE\n");
        let diags = check_src(&src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E080);
        assert!(diags[0].message.contains("must be bound"), "{diags:?}");
    }

    #[test]
    fn ref_param_bound_to_temp_is_e080() {
        let src = format!(
            "{HEAL}=== main ===\n~ temp local_hp = 10\n~ temp f = #fn(heal, local_hp)\n-> DONE\n"
        );
        let diags = check_src(&src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E080);
        assert!(diags[0].message.contains("frame"), "{diags:?}");
    }

    #[test]
    fn ref_param_bound_to_rvalue_is_e080() {
        let src = format!("{HEAL}=== main ===\n~ temp f = #fn(heal, 5 + 1)\n-> DONE\n");
        let diags = check_src(&src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E080);
        assert!(diags[0].message.contains("lvalue"), "{diags:?}");
    }

    #[test]
    fn ref_param_bound_to_const_is_e080() {
        let src = format!(
            "CONST LIMIT = 100\n{HEAL}=== main ===\n~ temp f = #fn(heal, LIMIT)\n-> DONE\n"
        );
        let diags = check_src(&src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E080);
        assert!(diags[0].message.contains("CONST"), "{diags:?}");
    }

    #[test]
    fn val_params_never_require_binding() {
        // `amount` (val) stays unbound — perfectly legal; only `hp` (ref)
        // must bind.
        let src = format!(
            "{HEAL}VAR player_hp = 10\n=== main ===\n~ temp f = #fn(heal, player_hp)\n-> DONE\n"
        );
        let diags = check_src(&src);
        assert!(diags.is_empty(), "{diags:?}");
    }

    #[test]
    fn zero_arg_creation_over_a_ref_free_target_is_clean() {
        // `#fn(name)` with zero bound args is legal iff the target has no
        // ref params (docs/t1c-spec.md §2).
        let src = format!("{PURE}=== main ===\n~ temp f = #fn(double)\n-> DONE\n");
        let diags = check_src(&src);
        assert!(diags.is_empty(), "{diags:?}");
    }

    // ── E081: over-binding ────────────────────────────────────────────

    #[test]
    fn binding_more_args_than_declared_is_e081() {
        let src = format!("{PURE}=== main ===\n~ temp f = #fn(double, 1, 2)\n-> DONE\n");
        let diags = check_src(&src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E081);
        assert!(diags[0].message.contains("2 argument"), "{diags:?}");
    }

    #[test]
    fn binding_exactly_the_declared_row_is_clean() {
        let src = format!("{PURE}=== main ===\n~ temp f = #fn(double, 1)\n-> DONE\n");
        let diags = check_src(&src);
        assert!(diags.is_empty(), "{diags:?}");
    }

    // ── Nesting ───────────────────────────────────────────────────────

    #[test]
    fn nested_fn_literal_inside_a_call_argument_is_checked() {
        let src = "VAR gold = 5\n=== main ===\n~ temp x = double(#fn(gold))\n-> DONE\n\
                   === function double(x) ===\n~ return x + x\n";
        let diags = check_src(src);
        assert_eq!(diags.len(), 1, "{diags:?}");
        assert_eq!(diags[0].code, DiagnosticCode::E079);
    }
}