pina_lints 0.22.0

Pina's official security lints: a lint catalog statically linked into the prebuilt pina_lint_driver binary
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
# `pina_lints`

<p align="center">
	<img src="https://raw.githubusercontent.com/pina-rs/pina/main/.github/assets/logo.png" alt="The Pina logo: a low-poly origami pineapple" width="140">
</p>

`pina_lints` is Pina's self-contained replacement for the previous [Dylint](https://github.com/trailofbits/dylint) setup: every security, performance, and IDL lint that Pina ships lives in this one importable crate, so the lints are built into Pina instead of being distributed as separate Dylint libraries. They turn repository security conventions into compiler diagnostics and are intended to run during normal development and CI.

The crate keeps the Dylint authoring shape — each lint lives in its own module under `lints` and declares itself with `declare_late_lint!` or `declare_pre_expansion_lint!` — but no lint registers itself; registration is centralized in `register_all_lints`. The crate builds as both a library and a cdylib that exports the Dylint-compatible `register_lints` symbol, so a Dylint driver can still load it as a single library. It also ships the bundled `pina_lint_driver` binary, a `rustc` wrapper with every lint statically linked; `pina lint` runs it as `RUSTC_WORKSPACE_WRAPPER`, so it needs no external lint tooling.

The lints complement tests and audits; they do not prove that a program's economic design is safe. Every path-sensitive lint documents the approximation it uses so findings can be reviewed with the right expectations.

<!-- {=crateReadmeBadgeRow:"pina_lints"} -->

[![Crates.io](https://img.shields.io/badge/crates.io-pina__lints-orange?logo=rust)](https://crates.io/crates/pina_lints) [![Docs.rs](https://img.shields.io/badge/docs.rs-pina__lints-1f425f?logo=docs.rs)](https://docs.rs/pina_lints/) [![CI](https://github.com/pina-rs/pina/actions/workflows/ci.yml/badge.svg)](https://github.com/pina-rs/pina/actions/workflows/ci.yml) [![Coverage](https://codecov.io/gh/pina-rs/pina/branch/main/graph/badge.svg)](https://codecov.io/gh/pina-rs/pina) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://opensource.org/license/apache-2.0)

<!-- {/crateReadmeBadgeRow} -->

## Installation and execution

Run the catalog shipped with your installed Pina CLI:

```bash
pina lint
# Apply machine-applicable suggestions, then inspect the diff.
pina lint --fix
```

`pina lint` resolves a `pina_lint_driver` built for the project's active toolchain — bundled next to the CLI, cached from a previous run, or downloaded from the Pina release matching the CLI — then invokes cargo with the driver as `RUSTC_WORKSPACE_WRAPPER`. Cargo calls the driver with the arguments it would have passed to `rustc`; the driver registers every lint compiled into this crate, and compilation continues normally. Cargo preserves and nests an existing `RUSTC_WRAPPER`, such as `sccache`, outside the lint driver. Because the lints are statically linked into the driver, no external lint tooling is downloaded or installed. The driver reads a few environment variables: `PINA_LINT_NO_DEPS` skips dependency crates, `PINA_LINT_LEVELS` forwards configured lint levels to `rustc` (see [Configuring lint levels](#configuring-lint-levels)), `PINA_LINT_ONLY` restricts linting to a single lint, and `PINA_LINT_LIST` prints the lint catalog instead of compiling. `PINA_LINT_NO_DEPS`, `PINA_LINT_LEVELS`, and `PINA_LINT_ONLY` are recorded in dep-info, so changing them invalidates cargo's cached check results.

The crate itself is nightly-only: the lint passes and the driver link against the Rust compiler's unstable `rustc_private` crates. Because those crates are not compatible across nightlies, a driver only loads against the exact compiler revision it was built with, so `pina lint` negotiates a driver for whatever toolchain the project activates rather than requiring one pinned nightly. The crate is published to crates.io; the CLI builds the driver from that release only when `pina lint --build-driver` is requested for a nightly Pina publishes no prebuilt driver for. Run `pina doctor` to see the active toolchain, the resolved driver, and the remedy when none resolved.

Pina contributors still run the in-workspace driver when changing a lint:

```sh
devenv shell -- security:pina-lint
```

`security:pina-lint` is the authoritative gate. It builds the workspace's `pina_lint_driver` binary and runs cargo with `RUSTC_WORKSPACE_WRAPPER` pointing at it, discovering every package under `examples/` and every `security/*/secure` fixture, then checks each one in the driver's no-deps mode with `--locked`. Insecure fixtures are intentionally excluded because they preserve examples of unsafe patterns.

## Importing the lints

Every lint constant and pass is public:

```rust,ignore
use pina_lints::lints::require_consistent_token_program::REQUIRE_CONSISTENT_TOKEN_PROGRAM;
use pina_lints::lints::require_consistent_token_program::RequireConsistentTokenProgram;
```

Tooling that needs to validate lint names or enumerate the catalog can read `pina_lints::LINT_NAMES`, which lists every lint in the crate in catalog (alphabetical) order.

## Configuring lint levels

Lint levels are configured in the project's `pina.toml` under the `[lints]` table. Each entry maps a lint name to `allow`, `warn`, or `deny`; lints that are not listed use their built-in default level (the "Level" column in the catalog below). The Pina CLI reads the table and passes the result to `pina_lint_driver` through the `PINA_LINT_LEVELS` environment variable; the driver forwards each level to `rustc` as an `--allow`, `--warn`, or `--deny` argument.

```toml
[lints]
deny_heap_allocations_in_onchain_instruction_handlers = "deny"
deny_colliding_account_discriminators = "deny"
require_explicit_discriminators_and_seed_namespaces = "allow"
```

Deny-level security lints should not be disabled at crate scope; see the [suppression policy](#suppression-policy) below.

## Complete lint catalog

| Lint                                                    | Level | Primary invariant                                   |
| ------------------------------------------------------- | ----- | --------------------------------------------------- |
| `require_program_check_before_cpi`                      | deny  | CPI targets are authenticated                       |
| `deny_heap_allocations_in_onchain_instruction_handlers` | warn  | On-chain handlers avoid unbounded allocation cost   |
| `require_writable_before_account_resize`                | deny  | Resize targets are writable                         |
| `require_zeroed_before_close`                           | deny  | Closed account data is invalidated                  |
| `require_sysvar_assert_before_sysvar_use`               | deny  | Sysvar accounts cannot be substituted               |
| `require_type_assert_before_zero_copy_cast`             | deny  | Raw account casts use guard-backed typed loading    |
| `require_reason_for_duplicate_remaining_accounts`       | deny  | Duplicate mutable remaining accounts are justified  |
| `deny_unchecked_remaining_mut`                          | deny  | Direct mutable remaining accounts reject aliases    |
| `require_canonical_bump_before_pda_write`               | deny  | PDA namespaces use canonical bumps                  |
| `deny_account_borrows_across_cpi`                       | deny  | Mutable data guards end before CPI                  |
| `deny_colliding_account_discriminators`                 | deny  | Account discriminator values stay globally unique   |
| `deny_unused_account_borrow_guards`                     | warn  | Unread borrow guards are discarded immediately      |
| `require_consistent_token_program`                      | deny  | Token validation and CPI share one program identity |
| `require_explicit_token_2022_extension_policy`          | deny  | Token-2022 extensions are explicitly allow-listed   |
| `require_post_cpi_balance_reload`                       | deny  | Token CPI destinations are reloaded after the CPI   |
| `require_checked_asset_arithmetic`                      | deny  | Economic arithmetic fails on overflow/underflow     |
| `require_guarded_full_balance_drain`                    | warn  | Full-balance drains are gated by a guard            |
| `require_bounded_remaining_accounts`                    | deny  | Caller-controlled account work has a visible bound  |
| `require_idl_root_to_define_one_program_id`             | warn  | IDL roots expose exactly one program ID             |
| `require_canonical_instruction_dispatch_for_idl`        | warn  | Entrypoints use discoverable instruction dispatch   |
| `require_explicit_discriminators_and_seed_namespaces`   | warn  | Examples expose type and PDA namespaces             |

## Security and correctness reference

### `require_program_check_before_cpi`

Detects `invoke_with_unverified_program()` and `invoke_signed_with_unverified_program()` calls without a proof for the exact dynamic program argument. Pina's `assert_program()`, `assert_address()`, or `assert_addresses()` must compare against a const, an immutable static whose type contains no interior mutability, or an unmodified local alias of one and succeed on every continuing path to the invocation. A value supplied through instruction data is not authentication: comparing two attacker-controlled values proves consistency, not identity. A const or static whose type contains `UnsafeCell`, such as `Mutex<Pubkey>`, a struct wrapping one, or a const of reference type aliasing one, can be rewritten at runtime and therefore does not establish a proof; a dynamic expected ID needs a narrowly scoped lint allowance with documented authentication. Enforce the assertion with `?`, `unwrap()`, or `expect()`. Failure-side `map_err()` and `inspect_err()` adapters are also accepted before extraction. Discarding the `Result` or inspecting failure does not establish a proof. Success-side `map()`, `and_then()`, and `inspect()` adapters do not establish a proof because their callbacks can replace the validated binding before execution continues. Assignments, mutable borrows, `&mut self` method calls, and closures that may replace a captured account or expected-ID alias invalidate the proof. Prefer `assert_program()` for an explicit program account because it checks both the address and the executable flag.

```rust
token_program.assert_program(&token::ID)?;
transfer.invoke_with_unverified_program(token_program.address())?;
```

Pinocchio Token's `.invoke_with_program()` and `.invoke_signed_with_program()` methods call `Program::verify()` themselves, so they do not need a separate assertion. Prefer those verified methods unless the handler has already validated the program account and deliberately needs the lower-overhead unverified variant. Static `.invoke()` and `.invoke_signed()` builders encode their target program and also need no separate program account assertion. Passing a constant such as `&token::ID` to an unverified invocation is accepted because the caller cannot substitute the value.

The analyzer resolves the validation method to Pina rather than trusting its spelling. It tracks authenticated account places separately from trusted expected-ID provenance, including aliases and the account value returned by a chained Pina assertion, then intersects both states across continuing control-flow paths. An unrelated account, attacker-controlled expected ID, or same-named local method cannot authorize the dynamic target. A check in one branch does not authorize a later call unless every continuing path establishes the same proof.

Call unverified CPI methods directly with method or UFCS syntax. Taking one as a function value is denied at the function item, including casts, assignments, containers, closures, and conditional expressions. This deliberate boundary keeps the exact target argument visible to the lint instead of approximating Rust's full value and closure data flow. If a reviewed abstraction must store one of these functions, use a narrowly scoped lint allowance and document how it authenticates the supplied program.

To migrate existing code, remove assertions that exist only before `.invoke()`, `.invoke_signed()`, `.invoke_with_program()`, or `.invoke_signed_with_program()`. Keep exact-target validation before the explicitly unverified methods. Propagate assertion failure, and move conditional checks so every continuing path validates the target. Existing code that used a verified method only to satisfy this lint needs no API change.

### `require_writable_before_account_resize`

Detects `resize()` without a preceding `assert_writable()` on the same account.

```rust
state.assert_writable()?;
state.resize(new_len)?;
```

The lint tracks lexical call order and receiver identity. It does not infer writability from comments, IDL metadata, or helper functions.

### `require_zeroed_before_close`

Detects `close()` or `close_with_recipient()` without first zeroing the same account's data. Prefer `close_account_zeroed()` or the `CloseAccountZeroed` builder, which zero the data and close in one step and are never flagged:

```rust
state.close_account_zeroed(&ID, recipient)?;
```

When the close must stay separate, clear the whole data buffer first:

```rust
state.try_borrow_mut()?.fill(0);
state.close_with_recipient(&ID, recipient)?;
```

This protects against stale bytes remaining observable during the transaction.

The zeroing proof is a `fill(0)` resolved to `core`'s slice method over the entire buffer returned by `solana_account_view`'s `AccountView::try_borrow_mut()?` (the type Pina and Pinocchio re-export), in method or fully qualified form: chained directly, through `[..]`, or through a `let` binding of that buffer that is later only dropped. Closes are recognized in both forms too, so `AccountView::close(state)` and `<AccountView>::close(&mut *state)` are checked like `state.close()`. A partial fill (`data[..8].fill(0)`), a non-zero or non-literal fill, a same-named non-slice `fill`, and a same-named `try_borrow_mut` on another type are not proofs. The fill must also be the last write: a later `try_borrow_mut()` that could reach the same account, or any use of the zeroed buffer other than `drop`, before the close voids it. Writes through other paths, such as a typed `as_account_mut()` loader or a CPI, are not tracked.

"Same account" means both receivers resolve to the same local binding plus field path. A `let` alias is followed only when its initializer is a plain place (`x`, `&x`, `&mut x`, `&mut *x`, `*x`, `x.field`), so `let alias = &mut *state;` names `state` and zeroing or closing through it counts. A binding initialized any other way, such as `let vault = next_account(&mut iter)?;`, is its own account rather than an alias of the call's argument. A receiver reached through indexing (`accounts[0]`) or a method or function call has no identity, so its close is always flagged; bind the account first. A binding that may hold a different value by the close has no identity either: one that is assigned (including through `*alias = ..`), lent as a slot (`&mut binding`, including `mem::swap` and `addr_of_mut!`), or captured by a closure.

The account's place must also stay put. Lending the place, or any place it is reached through, mutably anywhere before the close voids the proof, even when the lend comes before the zeroing. A lend is any of:

- a `&mut` borrow;
- a `ref mut` binding, or a `match`, `if let`, or `let` whose default binding modes borrow it mutably;
- passing a `&mut` place to a function;
- calling a `&mut self` method outside `solana_account_view`, `pinocchio`, and `pina`, including a user function or method that only shares a close name, which lends its receiver to every other close;
- a closure that captures the place mutably, or captures a `&mut` to it by value.

So `rotate(ctx)`, `ctx.rotate()`, `|| rotate(ctx)`, and `mem::swap(&mut ctx.escrow, ..)` all void a proof for `ctx.escrow`, while lending the sibling `&mut ctx.maker` does not. A lend or `try_borrow_mut()` through an alias whose value may have changed since its `let`, or through an expression the lint cannot place (such as a getter's result), is assumed to reach every account, so it voids every proof it could affect.

The zeroing must run on every path to the close: a fill inside an `if` or `else` branch, a `match` arm, the right side of `&&`/`||`, a loop body, a labeled block, or the `else` block of a `let ... else` proves only a close inside that same scope, because a condition, `break`, or failed pattern can skip it.

Known limits:

- Methods from `solana_account_view`, `pinocchio`, and `pina` are trusted not to replace the account, so a write to its data through one of them after the fill (such as a typed `as_account_mut()` loader) is not seen.
- A write by a CPI after the fill is not seen.
- A write through a separately obtained handle to the same account (a copied or cloned `AccountView`, or one returned by a call) after the fill is not seen.
- A lend that appears after the close inside a loop is not considered for the next iteration.
- The check is lexical within one function body, and zeroing inside a closure never counts.

### `require_sysvar_assert_before_sysvar_use`

Detects raw reads from accounts whose names identify known sysvars without a successful call to Pina's `assert_sysvar()` using the matching canonical `pina_sdk_ids::sysvar::<name>::ID`.

```rust
clock.assert_sysvar(&sysvar::clock::ID)?;
let data = clock.try_borrow()?;
```

Prefer Pinocchio's checked typed loaders when you need the sysvar value. They validate the account address while parsing, so a separate `assert_sysvar()` call would repeat the same check:

```rust
let clock = Clock::from_account_view(clock_account)?;
let rent = Rent::from_account_view(rent_account)?;
let instructions = Instructions::try_from(instructions_account)?;
```

Keep `assert_sysvar()` when code only validates identity or deliberately borrows the raw account data. A raw-access proof must call the resolved Pina method with the canonical ID. Known names such as `clock_account` must use the matching ID; generic names such as `epoch_sysvar` can establish proof with any recognized canonical sysvar ID. Enforce its `Result` with `?`, `unwrap()`, or `expect()` on every continuing control-flow path; chaining from the returned account value is supported. Failure-side `map_err()` and `inspect_err()` adapters are also accepted before extraction. Discarded results, failure inspection, same-named methods, look-alike ID constants, and one-branch checks are not proofs. Success-side `map()`, `and_then()`, and `inspect()` adapters are not proofs because their callbacks can replace the asserted account before a later raw read. Assignments, mutable borrows, `&mut self` method calls, and closures that may replace a captured account invalidate the earlier proof.

The lint identifies Pinocchio constructors that do not validate identity by their resolved definition: `Clock` and `Rent` byte constructors, `Instructions::new_unchecked`, and `SlotHashes::new` / `new_unchecked`. It reports direct calls at their source and rejects storing these constructors as function values. This source boundary catches replacement through adapters, helper calls, mutable borrows, aliases, and destructuring without attempting to reconstruct arbitrary downstream value provenance.

To migrate existing code, replace manual byte parsing with the matching checked typed loader. Checked results and checked constructor function values can use ordinary Rust extraction, adapters, tuples, patterns, and control flow without special lint knowledge. Call an identity-unchecked constructor directly so its source remains visible to the lint. For deliberate raw parsing, call `assert_sysvar()` before borrowing the data, then place a narrow lint allowance directly on the reviewed constructor. No change is needed for identity-only checks or asserted raw account access. The separate raw-read heuristic still uses standard Solana sysvar account names, so unusually named raw accounts may require a direct, local assertion.

```rust,ignore
rent_account.assert_sysvar(&sysvar::rent::ID)?;
let data = rent_account.try_borrow()?;
// Reviewed exception: the preceding assertion fixes the raw data's identity.
#[allow(require_sysvar_assert_before_sysvar_use)]
let rent = Rent::from_bytes(&data)?;
```

### `require_type_assert_before_zero_copy_cast`

Detects known `bytemuck` cast functions in `pina::ProcessAccountInfos::process` implementations and conventional `process_instruction` entrypoints. Unrelated functions or inherent methods named `process` remain outside the lint boundary. Use a Pina conversion that validates and borrows the account as one operation.

```rust
let vault = account.as_account::<Vault>(&ID)?;
```

For PDAs, prefer the generated `load_pda*` methods because they also validate the address and stored bump. `assert_type::<T>()` remains useful when a handler only needs validation, but it is a moment-in-time check and does not make a later raw cast safe. Pina instruction and account `try_from_bytes()` associated functions are safe framework conversions and are not treated as raw casts.

### `require_reason_for_duplicate_remaining_accounts`

Detects `#[pina(remaining, distinct = false)]` on mutable remaining accounts unless the field has a doc-comment explanation of at least five words.

```rust
/// Duplicate entries represent votes and are deduplicated before mutation.
#[pina(remaining, distinct = false)]
pub votes: &'a mut [AccountView],
```

`#[pina(remaining)]` is distinct by default. The word threshold only rejects missing or placeholder explanations; reviewers must still verify the stated invariant.

### `deny_unchecked_remaining_mut`

Detects direct calls to Pina's `AccountsCursor::remaining_mut()`. The method validates writability but preserves duplicate addresses, so one logical account can appear more than once in the returned mutable slice.

```rust
let remaining = cursor.remaining_mut_distinct()?;
```

The lint resolves the method definition before reporting, so same-named methods from other crates are ignored. It rejects method syntax, UFCS calls, stored function items, and calls hidden by local or external macros. Pina's `#[derive(Accounts)]` expansion remains exempt: the macro uses `remaining_mut()` only for the explicit, documented `#[pina(remaining, distinct = false)]` escape hatch, which is checked separately by `require_reason_for_duplicate_remaining_accounts`.

### `require_canonical_bump_before_pda_write`

Detects `assert_seeds_with_bump()` in instruction paths unless the same account has already passed `assert_canonical_bump()` or `assert_seeds()`.

```rust
let canonical = state.assert_canonical_bump(&seeds, &ID)?;
if canonical != supplied_bump {
	return Err(ProgramError::InvalidSeeds);
}
state.assert_seeds_with_bump(&seeds_with_bump, &ID)?;
```

Multiple valid bump values can otherwise create multiple addresses for one logical namespace. See Solana's [PDA documentation](https://solana.com/docs/core/pda). The lint tracks lexical receiver identity; it cannot inspect opaque validation helpers.

This lint applies to validation-only assertion chains. `CreateProgramAccountWithBump` and `CreateCompactProgramAccountWithBump` enforce canonicality inside the builder, so creation handlers should not call either assertion first. Prefer the canonical builders when the instruction does not need to carry a bump.

### `deny_account_borrows_across_cpi`

Detects CPI while a local returned by `try_borrow_mut()` or `as_account_mut()` is still alive.

```rust
let amount = {
	let state = account.as_account_mut::<State>(&ID)?;
	state.amount.get()
};
transfer.invoke()?;
```

An explicit `drop(guard)` or the end of a nested block releases the guard. The analysis follows block scope, locally bound closure calls, match guards, nested binding patterns, guard-returning aliases, and calls to the real `std::mem::drop`. Closure bodies are evaluated with the borrow state at each visible invocation, so defining a callback before a borrow or dropping a borrow before invoking it is modeled in execution order. It resolves method and guard types before classifying a borrow or CPI, so unrelated same-named operations do not create or discharge a proof. Account borrows hidden inside custom wrapper constructors, closures invoked through opaque higher-order helpers, and CPIs hidden behind opaque helpers are outside its current model.

### `deny_unused_account_borrow_guards`

Detects account borrow guards bound to locals that are never read.

```rust
// Flagged: the guard is bound but never read, so the account data borrow
// stays open until the end of the enclosing scope for nothing.
let _guard = mint.as_token_mint_for_program(&token_program)?;

// Preferred: discard the validation value immediately.
mint.as_token_mint_for_program(&token_program)?;
```

Assertion-style guards exist only for their `?` validation. Binding them without reading keeps the borrow open to the end of the scope, which obscures the borrow boundary and can turn a later borrow of the same account data into a runtime panic. Discard immediately by calling the validation as a `?` statement — optionally wrapped as `drop(account.try_borrow()?)` — or write `let _ = account.try_borrow()?;` at the creation site; when the value matters, read it. `let _ = guard;` does not move an existing local in Rust, so it does not release the borrow and remains a lint warning. Passing a bound guard to a later `drop(local)` is also flagged because the borrow stayed open in between. The lint recognizes the concrete `solana_account_view::Ref` and `RefMut` binding types re-exported by Pinocchio and Pina, including aliases, values returned through function pointers, and bindings nested in tuple or `let ... else` patterns. It counts any use of the binding — method calls, field access, `&` borrows, and closure captures — as a read. Wrapper types that contain a guard are outside its current model. A `drop(local)` call is treated as a discard rather than a read only when it resolves to `std::mem::drop`; a shadowing `drop` function is an ordinary use.

The warning carries a suggested rewrite: `pina lint --fix` rewrites the binding into the immediate `?` statement. When the only other occurrence of the binding is a later `drop(local);`, the suggested edit also removes that statement — but it is marked `MaybeIncorrect` rather than machine-applicable, because releasing the borrow earlier than the user wrote it is observable to the code in between and needs human review. Suggestions are withheld entirely when the guard binding originates inside a macro expansion or when a `drop` appears outside a statement (for example inside a closure tail), because the rewrite could not be applied safely there.

### `require_consistent_token_program`

Detects token parsing, ATA derivation, and dynamic token CPI calls that use different program identities within one instruction function.

```rust
token_program.assert_addresses(&SPL_PROGRAM_IDS)?;
let program_id = *token_program.address();
let mint = mint.as_token_mint_for_program(&program_id)?;
transfer.invoke_with_program(&program_id)?;
```

The lint compares resolved identifier paths, including module-qualified constants, so `token::ID` and `token_2022::ID` cannot collapse to the same terminal name. Immutable local aliases are traced back to their original identity, allowing clear names for parsing and CPI without reporting a mismatch. It still rejects reassignment of a program binding between token operations, because the same lexical name would otherwise hide a changed value. Copy and reuse a single immutable, validated address instead of independently deriving, mutating, or hard-coding program IDs.

### `require_explicit_token_2022_extension_policy`

Detects Token-2022-capable mint loads without an explicit call to `assert_no_extensions()` or `assert_extensions_allowed()` in the instruction function.

```rust
let mint = mint_account
	.as_token_mint_for_program(&program_id)?
	.assert_extensions_allowed(&[
		token_2022::state::ExtensionType::ImmutableOwner,
	])?;
```

Extensions can alter transfer, fee, hook, freeze, and authority semantics. Pina therefore requires an allow-list instead of treating the legacy base layout as a complete policy. The analysis pairs a policy with the concrete mint-view binding or with the same direct method chain; a policy asserted on a different mint does not satisfy the rule. Keep each policy adjacent to its mint load so the pairing also remains obvious to reviewers.

The analysis preserves a checked view through resolved `Result` extractors such as `?`, `unwrap()`, and `expect()`. It also handles adapters that cannot replace the successful value, such as `map_err()` and `inspect()`. The lint does not infer that `map()` or `and_then()` is an identity transform, even when a closure appears to return its input. Bind and assert the checked view before a custom success-value transformation. After you review another pattern, use a narrow allowance.

An `as_token_mint_for_program(&token::ID)` call with the canonical legacy SPL Token ID is exempt because Token-2022 extensions cannot be present. Dynamic program identities and the explicit Token-2022 loaders still require a policy.

Both policies are inherent, chainable methods on `TokenMintRef` and `TokenAccountRef`; they return the validated view rather than wrapping it in a separate free-function API.

`as_token_mint_for_program()` and `as_token_account_for_program()` only accept the canonical SPL Token and Token-2022 program IDs, require the account owner to match the selected ID, and parse the corresponding concrete layout. The caller therefore cannot make a legacy account appear to be Token-2022 (or vice versa) by supplying an arbitrary address. Extension assertions are a no-op on the validated legacy variant and inspect the actual TLV extension data on the validated Token-2022 variant.

### `require_post_cpi_balance_reload`

Detects a token balance snapshot that is trusted after a value-moving token CPI changed the balance it describes. Two tiers apply to every builder of a `Transfer`, `TransferChecked`, `MintTo`, or `MintToChecked` instruction:

- **Snapshot tier (every destination).** After a value-moving CPI into an account, two kinds of value are tracked:
  - A **snapshot-derived** value is an integer read of the account's balance taken before the CPI, or any local, conversion, or arithmetic result computed from one.
  - A **reload** is a read of the same account after the CPI that runs on every path to the use. It may not sit only inside an `if` arm, a closure, or a loop the use is outside of. A **reload-derived** value is a reload, or any local, conversion, or arithmetic result computed from one.
  - Conversions carry the value unchanged: `as` casts, borrows, and the integer-to-integer `From::from`, `Into::into`, `TryFrom::try_from`, and `TryInto::try_into`, resolved through their traits, with any `?`, `unwrap`, `expect`, or `map_err` after the fallible forms. So `u128::from(after).checked_sub(u128::from(before))`, `i128::from(after) - i128::from(before)`, and `let before: u128 = ata.amount().into()` work like the plain `u64` forms.

  After the CPI, a snapshot-derived value may appear only as:

  1. one side of a comparison (`==`, `!=`, `<`, `<=`, `>`, `>=`, `.eq()`, `.cmp()`, ...) whose other side is reload-derived or a constant, looking through `&`. This verifies the real balance: `if after != before + 10`, `let expected = before.checked_add(10)?; if after != expected`, `before.cmp(&after)`, and `if prior == 0` all pass;
  2. the subtrahend of a subtraction-like operation whose minuend is a reload: `-`, `checked_sub`, `saturating_sub`, `wrapping_sub`, or `overflowing_sub`, in method or function-call syntax (`u64::checked_sub(after, before)`), or either operand of the symmetric `abs_diff`. The result is a **delta**, which is reload-derived and no longer stale: `after - before`, `after.checked_sub(before)`, `after as u128 - before as u128`. The reverse sign (`before - after`) is not the amount received and stays snapshot-derived; or
  3. an operand of an addition-like operation (`+`, `checked_add`, `saturating_add`, `wrapping_add`, `overflowing_add`) whose other operand is a delta, directly or through a local: `before + (after - before)`, or `let delta = after.checked_sub(before)?; before.checked_add(delta)`.

  Every other appearance is a stale use:

  - other arithmetic whose result is then returned, stored, or passed on;
  - a call argument, a return value, or a tuple, struct field, or array element;
  - an addition with a bare reload (`before.checked_add(after)`); and
  - arithmetic that cancels the reload out (`before + after * 0`, `before.wrapping_add(after - after)`).

  A value bound to a local that is never read goes nowhere and is not reported. This covers `user_stake_ata`, `treasury`, `fee_receiver`, and any other name. Unrelated CPIs between the transfer and the reload are allowed.

  Logging or emitting the pre-transfer balance is a stale use by design: an event that reports `before` as a balance after the CPI publishes a value the chain no longer holds. Log the reload and the delta instead (`log(after); log(received)`). If the old balance must be recorded, compute and record it before the CPI, or place a narrowly scoped `#[allow(require_post_cpi_balance_reload, reason = "...")]` on the handler. Likewise, after verifying `if after != expected { return Err(..) }`, return `after` rather than `expected`.
- **Custody tier (custody-named transfer destinations).** A transfer into an account whose name contains `vault`, `custody`, `reserve`, or `pool` must be bracketed by destination reads with no other CPI in between, even when no snapshot exists yet, because a custody deposit is only safe to credit from the observed delta. Where the typed identity below cannot name the destination or one of its reads, the tier falls back to the name-based check (reads whose written receiver matches the written destination), so code that check accepts is not newly rejected for that reason.

```rust
let before = user_stake_ata.as_token_account_for_program(&program_id)?.amount();
transfer.invoke_with_program(&program_id)?;
let after = user_stake_ata.as_token_account_for_program(&program_id)?.amount();
let received = after.checked_sub(before).ok_or(ProgramError::ArithmeticOverflow)?;
```

Token-2022 transfer fees can make `received` differ from the requested amount; Solana's [on-chain Token-2022 guide](https://www.solana-program.com/docs/token-2022/onchain) describes this accounting requirement.

**What counts as a builder.** A `new` or `with_multisig_signers` constructor qualifies when all of the following hold:

- Its resolved return type, after unwrapping `Result` and `Option`, has a name ending in `Transfer`, `TransferChecked`, `MintTo`, or `MintToChecked`. So `SplTransfer` and the real `transfer_checked::TransferChecked` both count.
- Its signature leads with parameters that are references to a struct or generic type, followed by an integer amount.
- It leads with enough account parameters. A builder defined in a token crate (`pinocchio_token`, `pinocchio_token_2022`, `spl_token`, `spl_token_2022`, `spl_token_interface`, or `pina`) needs three: it is a token instruction by where it comes from. A builder defined anywhere else needs four for a transfer (`from, mint, to, authority`), because naming the mint is what a lamport transfer never does, and three for a mint.
- It is not defined in `pinocchio_system` or `solana_system_interface`.

So `AuthorityTransfer::new(config, new_authority, signer)` (no amount) and a local `LamportTransfer::new(payer, vault, system_program, lamports)` (no mint) do not count. The destination is the third account when four lead and the second otherwise.

**Which account an expression names.** Every local is keyed by its binding, never by its name, so shadowed locals and `let`-`else`, `if let`, and `match` bindings that share a name stay distinct. Fields extend the key with their full path, so `ctx.user_ata`, `ctx.fee_ata`, and `ctx.vault` stay distinct whatever their field types are. Only these steps are looked through:

- `let` aliases, `&`, `*`, and `?`;
- Pina's token-view methods (`as_token_account()`, `as_token_account_for_program()`, `as_token_2022_account()`, `as_associated_token_account()`, and `as_account()`);
- the token crates' state loaders (`TokenAccount::from_account_view()` and the `_unchecked`/`from_account_info` variants), keyed by their first argument;
- the `.base` field of a loaded Token-2022 view; and
- `Option`/`Result` adaptors that pass the success value through (`ok_or`, `ok_or_else`, `unwrap`, `expect`, `map_err`), and Pina's `assert_*` checks, which return the account they checked.

Cursor methods get a key unique to their call site, so two `it.next()` calls never name the same account. These are `Iterator::{next, nth}`, `DoubleEndedIterator::{next_back, nth_back}`, and Pina's `AccountsCursor::next*`. Every other method call with constant arguments is keyed by its receiver, the method's resolved definition, and its arguments, whether or not it takes `&mut self`. So an accessor such as `ctx.vault_mut()` names the same account on every call, and `accounts.get(2)` differs from `accounts.get(3)`. A `let` binding initialized from a non-cursor `&mut self` method call (looking through `?` and `Option`/`Result` adaptors) is keyed by that call and its binding name. So `let fee = cursor.take()?; let vault = cursor.take()?;` never collide, while rebinding the same accessor under the same name (`let vault = ctx.vault_mut();` before and after the transfer) names one account, as the name-based check treats it. Because such a call may be an accessor or a hand-written cursor, the custody tier defers to the name-based verdict whenever the destination or a balance read passes through such a binding. Anything else, such as a dynamic index or a call with a non-constant argument, names no account, and a read that names no account never matches a destination.

**What counts as a read.** `.amount()` and `Type::amount(account)` count, outside closures. A read inside a closure only happens if the closure runs, so it counts for neither tier. Snapshots are followed through tuple destructuring, verbatim copies (`let snapshot = before;`), and assignments (`before = ata.amount();`).

**Unreachable uses.** A use the CPI cannot reach is not stale: the CPI sits in a block that always returns, or the use is in a sibling `if`/`match` arm.

**Legacy program exemption.** A static `invoke()` or `invoke_signed()` is exempt when both of these hold:

- its receiver's full type is the constructed builder; and
- that builder's program type parameter is `pinocchio_token::TokenProgram`.

That call targets the legacy SPL Token program, which has no transfer-fee extension, so the requested amount is exactly what arrives. The following stay covered:

- a wrapper's `invoke()`;
- any expression that yields a Token-2022 builder instead, such as `pick(legacy, token_2022).invoke()`;
- Pina's `token_2022` aliases;
- local look-alikes; and
- every runtime-program invocation (`invoke_with_program()`, `invoke_with_unverified_program()`, and their signed variants).

**Limits.**

- A snapshot behind a helper function (`let before = read_balance(ata)`) or stored in a struct field (`Snap { before: ata.amount() }`) is not tracked.
- A snapshot-derived value that flows into a non-integer local (such as `let x: Option<u64> = before.checked_add(10);`) is not followed further and is reported at that binding.
- A snapshot that starts out as a non-integer value, such as `Some(ata.amount())` or `ata.amount().checked_add(0)` bound to an `Option<u64>`, is never tracked, so its later uses are not checked.
- A delta that cancels itself out (`let d = after - before; before + d - d + 10`) is accepted: the addition with the delta makes the result reload-derived, and later arithmetic on a reload-derived value is not re-examined.
- A destination that names no account gets no snapshot analysis. The custody tier still requires reads for it, through the name-based fallback.
- A local builder that transfers without naming the mint (`from, to, authority, amount`) is only covered when it comes from a token crate.
- A `&mut self` method other than the listed cursors, called inline more than once (`cursor.take()?.amount()` twice), is assumed to return the same account on every call with the same constant arguments. Bind each result with `let` to give it its own identity. Binding two results of the same call under the same name treats them as one account. So rebinding a hand-written cursor as `let acct = cursor.take()?;` before and after the transfer counts the second account's read as the first account's reload, and a stale snapshot of the first account is not reported. Give each cursor result its own name.
- A read through a `let`-bound `&mut self` result and a transfer into a separate inline call of the same method (`let vault = ctx.vault_mut(); let before = vault.amount(); transfer(ctx.vault_mut())`) are different identities, so the snapshot tier does not check that snapshot. Use the binding for both the reads and the transfer.
- A local's value is taken from its lexically latest definition before the use. Writes through `&mut` references to it are not tracked.
- Builders passed through opaque wrappers are not associated with their invocation.
- Code is ordered lexically, so loops are analysed in source order.

Audit such code manually or keep the transfer and the reads direct in the instruction handler.

### `require_checked_asset_arithmetic`

Detects raw `+`, `-`, `*`, and `/`, plus saturating or wrapping arithmetic, when an operand has an economic identifier component such as `amount`, `balance`, `lamport`, `price`, `reward`, `stake`, or `supply`.

```rust
let next_balance = balance
	.checked_sub(amount)
	.ok_or(ProgramError::ArithmeticOverflow)?;
```

Saturating arithmetic is rejected because silently clamping economic state can violate conservation just as surely as wrapping. The check applies to primitive integers; custom domain types own their arithmetic contract and are not given an inapplicable `checked_*` suggestion. Components are split at Rust identifier separators, so `vault_balance` is covered while an unrelated name such as `rebalance_attempts` is not. The naming heuristic favors clear domain names and may not recognize opaque abbreviations.

### `require_bounded_remaining_accounts`

Detects loops whose source mentions `remaining` unless the iterator visibly uses `.take(MAX)` or a dominating constant-bound length guard rejects oversized input first.

```rust
const MAX_REMAINING_ACCOUNTS: usize = 16;
if remaining.len() > MAX_REMAINING_ACCOUNTS {
	return Err(ProgramError::InvalidArgument);
}
for account in remaining {
	process(account)?;
}
```

Remaining accounts are caller-controlled; an explicit bound keeps worst-case compute auditable. Rejecting an oversized list is preferred when every supplied account must be processed, while `.take(MAX)` is suitable only when ignoring surplus accounts is intentional. Standard adapters that cannot increase cardinality, such as `filter`, `map`, and `enumerate`, preserve a preceding `take`; expanding adapters such as `flat_map` must be bounded afterward. The guard must compare `remaining.len()` against an integer literal or resolved constant, return early on the oversized path, and dominate the loop. The analysis follows local aliases and computes loop-carried state to a fixed point. Reassignment, mutable borrows, `&mut self` calls, and closures that may replace a checked binding invalidate its bound, including for later iterations of an enclosing loop. A runtime limit, branch-local check, late check, or opaque helper does not satisfy the rule because it does not establish a source-visible protocol maximum on every path.

### `require_guarded_full_balance_drain`

Detects an instruction handler that sends an account's entire `lamports()` balance with `send` or `send_owned` unless a pause, circuit-breaker, or withdrawal-cap guard is enforced earlier in the same or an enclosing scope (not inside a branch, loop body, closure, or the right operand of `&&`/`||`), or the same account is closed first.

```rust
fn enforce_withdrawal_policy(config: &VaultConfig) -> Result<(), ProgramError> {
	assert_not_paused(config)?;
	config.assert_within_window_cap()
}

enforce_withdrawal_policy(&config)?;
vault.send_owned(&ID, vault.lamports(), recipient)?;
```

A call counts as the guard only when all of the following hold:

- **Its failure stops the handler.** A `Result`/`Option` guard is propagated with `?`, extracted with `unwrap()`/`expect()`, returned, or tested by a `match`, `if let`, or `let ... else`. Every arm that can receive the failure must return `Err`/`None` (or evaluate to one when the whole expression is itself returned or propagated), return the scrutinee's own binding, or panic. Arms are read in order, so a `_` after an unguarded `Err(_)` arm only receives success. Adapters that keep the failure are followed: `map_err`, `inspect_err`, `map`, `and_then`, `and`, `ok`, `ok_or`, `or(Err(..))`, `or_else` whose fallback can only fail, `clone()`, and `into()`/`From::from` into a `Result` or the same type. `or(Ok(..))`, `or(Some(..))`, a recovering `or_else`, `unwrap_or`, a conversion into `Option<Result<..>>`, and a discarded result are not.
- **Polarity is checked.** A `bool` guard, `is_err()`/`is_ok()`, or `eq`/`ne`/`==`/`!=` against `Ok(..)` of a fallible one must gate an `if`, `assert!`, `assert_eq!`, `assert_ne!`, or `pina::assert(ok, error, message)?` so that execution continues only on the passing value. So `if guard().is_ok() { return Ok(()) }`, `assert_eq!(guard().is_err(), true)`, and `match guard() { Ok(()) => return Err(..), _ => {} }` do not gate the drain that follows. A guard that returns a `bool` itself has no known polarity, so a failing branch on either side of its `if` counts.
- **A failing branch fails.** It returns `Err`/`None`, returns a local helper that can only fail (`return reject()`), or panics. A guard-named local method returning `()`, such as `fn assert_not_paused(&self) { assert!(!self.paused) }`, counts where it is called when its body can panic. A branch that returns `Ok`, `break`s, or `continue`s is not a failure. An early `return Ok(..)` on the paused path, such as `if state.is_paused() { return Ok(()) }`, deliberately does not count. For a `bool` guard the lint cannot tell which value is the failing one, and reporting success for a blocked sweep hides the pause from callers.
- **It reads the handler's inputs.** Its receiver or an argument must be derived from a function parameter (including `self`), directly or through locals bound from one. A zero-argument call, or one fed only literals and constants (even through a local such as `let zero = 0;`), cannot inspect the state it claims to guard.
- **It names the check, or delegates to one.** It is a function or method whose name contains `pause`, `cap`, `circuit`, `halt`, `guard`, `limit`, or `throttle`, because behavior alone cannot tell a cap check from `assert_signer()?`, which every handler propagates. Closures, fn pointers, and generic callables are named by their binding and never count by name. A differently named local function that returns `Result`/`Option` counts when its own body enforces such a guard in its outermost scope before any early success `return` or `break`, followed up to three wrappers deep, so `enforce_withdrawal_policy(&config)?` above is accepted. A wrapper returning `bool` is never followed. A generic wrapper is instantiated with its caller's arguments, so `fn policy<T: Guarded>(t: &T) -> Result<..> { t.check_cap() }` is judged by the impl its caller passes, not by the name `check_cap`. Inside a wrapper, a trait call that cannot be resolved (`dyn`, an unconstrained generic) counts as neither a guard nor a failure, and so does a `return` of a value the lint cannot see into (`return Ok(()).into()`, `return identity(Ok(()))`, `return finish.finish()`). A guard bound to a local counts where the local is enforced, unless it is reassigned or mutably borrowed first. A labeled block that can `break` with a success does not carry its tail guard out.
- **It is not a constant success.** A local callee whose every returned value is a literal `Ok(..)`/`Some(..)` (or a `bool` literal), directly or through a never-reassigned `let` binding, and that has no reachable `?`, `Err`/`None`, or panic, is not a guard, whatever its name. Branches behind a literal `if true`/`if false` count as unreachable. Trait method calls are judged by the implementation that runs, never by a default body that the implementation overrides. In a generic handler (not a wrapper) where the implementation cannot be resolved, only the method name is used.

Known limits:

- Callees from other crates are judged by their name and call-site behavior only, and a unit guard from another crate never counts. In the handler itself, but not in a wrapper, a `return helper()` on the failing side whose helper cannot be analyzed counts as a failing branch, as the name rule did before, because any `return` there skips the drain.
- `async fn` handlers and guards are not analyzed through `.await`, which does not arise in SBF programs.
- A local guard-named callee counts if anything in its body can fail, even for reasons unrelated to its claimed check. The constant-success test does not evaluate conditions beyond literal `true`/`false`.
- Wrappers deeper than three levels are not followed.
- A pause check with no guard-named call, such as `if state.paused { return Err(..) }` or a differently named helper taking only the flag, is not recognized.
- Some correct guards are still reported, because the lint errs toward warning when it cannot prove the failure stops the handler:
  - `unwrap_or_else(|_| panic!(..))` on a guard's result;
  - a guard result that is reassigned before it is propagated (`res = res.map_err(..); res?`);
  - a guard bound through tuple destructuring;
  - a unit guard that fails through `.expect()` rather than `assert!`/`panic!`;
  - a guard whose receiver is a `static`.

  Propagate the guard's result directly with `?` to satisfy the lint.

## Performance reference

### `deny_heap_allocations_in_onchain_instruction_handlers`

Warns on `collect`, `to_vec`, `to_string`, `clone`, `format!`, `Vec` creation, and `String` creation in functions whose names identify instruction handlers.

```rust
let mut bytes = [0u8; MAX_MESSAGE_BYTES];
bytes[..input.len()].copy_from_slice(input);
```

The lint is a performance warning rather than a correctness denial because some off-chain or bounded on-chain designs may intentionally allocate. It uses method and function-name heuristics and does not estimate actual heap size.

## IDL and example-structure reference

### `require_idl_root_to_define_one_program_id`

Warns when an IDL-oriented example or security crate does not expose exactly one crate-root `declare_id!` expansion.

```rust
declare_id!("Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS");
```

The check is repository-scoped by source file: `declare_id!` expansions are inspected in crates whose sources live under an `examples` or `security` directory. The crate-root program id is the contract, and every additional declaration is reported at its own call site, so module-scoped `#[allow(...)]` suppresses only the extra declarations (as `examples/declare_program` demonstrates). Library crates that intentionally define no program are ignored.

### `require_canonical_instruction_dispatch_for_idl`

Warns when `process_instruction` or an entrypoint does not directly contain a `match` over parsed instruction data.

```rust
match instruction {
	Instruction::Initialize => InitializeAccounts::try_from((program_id, accounts))?.process(data),
	Instruction::Update => UpdateAccounts::try_from((program_id, accounts))?.process(data),
}
```

The check keeps dispatch visible to `pina idl` and reviewers. It verifies the presence of direct match-shaped routing, not semantic exhaustiveness.

### `require_explicit_discriminators_and_seed_namespaces`

Warns when seed assertions in example instruction paths do not visibly use a byte-string namespace, a named `SEED`/`SEED_*`/`*_SEED` constant, or a generated Pina seed helper.

```rust
const SEED_VAULT: &[u8] = b"vault";
vault.assert_seeds(&[SEED_VAULT, authority.address().as_ref()], &ID)?;
```

Associated seed helpers generated from `#[pda(...)]` are accepted because the macro declaration exposes the namespace at the account type. Receiver-less local functions named like assertion methods are not treated as framework proof. The rule is a reviewability warning and does not replace canonical bump validation.

## Suppression policy

Prefer making validation and bounds explicit instead of suppressing a finding. When a false positive cannot be expressed more clearly, scope `#[allow(...)]` to the smallest item and add a doc comment explaining the invariant. Deny-level security lints should not be disabled at crate or workspace scope.

## Testing

UI fixtures live under `tests/ui/<lint>/`. Each fixture is compiled with the bundled `pina_lint_driver` — the lints are statically linked into the driver — and the emitted diagnostics are compared with the committed `.stderr` file next to the fixture. `PINA_LINT_ONLY` restricts the driver to the lint under test, so each fixture observes the same single-lint behavior the previous one-library-per-lint Dylint setup had.

Fixtures support two directives:

- `// aux-build: <name>.rs` — compile `auxiliary/<name>.rs` first and pass it to the fixture through `--extern`. The auxiliary source chooses its own crate type through `#![crate_type]` (for example proc-macro fixtures); sources without an inner attribute fall back to a plain library.
- `// normalize-stderr-test: "<regex>" -> "<replacement>"` — rewrite the actual stderr before comparing it with the expectation. Paths under the fixture directory are replaced with `$DIR` first, mirroring the convention of the Rust repository's UI tests.

To update a `.stderr` expectation, run the test, copy the saved actual stderr over the `.stderr` file, and re-run. On a mismatch the harness saves the actual stderr to a `pina-lints-ui` directory under the system temp directory and prints the saved path in its failure report.