check: precise sub-binder analysis for over-strict-mode (iter 19a.1)

Corpus-signal upgrade: orchestrator ran 19a's lint across 65
fixtures, zero warnings fired. The conservative match-scrutinee
carve-out filtered every (own T) fixture because every such body
destructures the param via match. Real-corpus signal warranted
the precise variant.

Precise rule: for an Own param with consume_count == 0, walk each
match arm whose scrutinee is the param; if any HEAP-TYPED
pattern-binder has consume_count > 0, stay silent (sub-consume
forces ownership). Otherwise fire.

Heap-type filter is the implementer's design call (not in the
brief): reading a primitive (h: Int) is load-by-value, no RC, no
heap-data move; reading heap-typed t: IntList IS a pointer move
requiring outer-cell ownership. Documented in module-doc.

Corpus signal after upgrade: 5/65 fixtures fire — all RC
codegen-test fixtures with deliberate over-strictness for the
codegen path. Justifies 19b (mode-strict-because suppression).

ailang-check test count: 53 → 55. e2e + everything else green.
Known debt: deeply-nested-match-on-sub-binder (no fixture hits).
This commit is contained in:
2026-05-08 19:19:31 +02:00
parent b9e942b99d
commit 9f3f10ce6e
2 changed files with 464 additions and 78 deletions
+91
View File
@@ -9244,3 +9244,94 @@ JOURNAL queue: empty again.
- `docs/JOURNAL.md` — this entry.
No code changes. Build/test status unchanged from 20d (green).
## 2026-05-08 — Iter 19a.1: precise sub-binder analysis (corpus-driven upgrade)
The 19a-shipping entry queued a sub-binder-precise variant for "if
real-corpus signal warrants." I (orchestrator) ran 19a's lint
across all 65 fixtures: **zero warnings fired**. The conservative
match-scrutinee carve-out filtered every `(own T)` param because
every such fixture's body destructures the param via `match`
even ones that are deliberately over-strict (RC codegen-test
fixtures like `head_or_zero` and `use_first`). The conservative
cut wasn't "incomplete but valuable"; it was "silent in the only
shapes the corpus actually produces." Real-corpus signal warranted.
### Precise rule
For an `Own` param `p` with `consume_count == 0`:
- For every match arm whose scrutinee is `p`, look at each
pattern-binder.
- If any **heap-typed** pattern-binder has `consume_count > 0`,
`p` must be `Own` (sub-consume forces ownership). Lint stays
silent.
- Otherwise, lint fires.
### The heap-type filter (design call by implementer)
The orchestrator's literal brief said "any pattern-binder
`consume_count > 0` → silent." That contradicted the brief's own
positive test (`head_or_zero` MUST fire), because `match xs {
Cons(h, t) => h }` records `consume_count(h) == 1` regardless of
`h: Int` being a primitive. Implementer caught this, made the
design call, documented it in the module-doc rationale.
The semantic justification: reading a primitive (`h: Int`) is a
load-by-value, no RC traffic, no heap data moved out of the
scrutinee's allocation. Reading a heap-typed sub-binder
(`t: IntList`) IS a heap-pointer move that requires owning the
outer cell. So the filter — "skip primitive-typed pattern-binders
when judging sub-consume" — is exactly what makes the lint
correct.
This is a real design point that 19a's brief did not document.
Recording it here as a ratification of the implementer's call.
### Corpus signal after upgrade
Five of 65 fixtures now produce at least one `over-strict-mode`
warning:
- `rc_app_let_partial_drop_leak`
- `rc_drop_iterative_long_list`
- `rc_match_arm_partial_drop_leak`
- `rc_own_param_drop`
- `rc_own_param_partial_drop_leak`
All five are RC codegen-test fixtures — the `(own T)` annotation
is intentional, used to exercise the codegen path that drops Own-
params at fn return / arm close. They are NOT incorrect annotations;
they are deliberate test infrastructure.
This is the signal that **justifies 19b** (`mode-strict-because`
suppression). Without it, every RC-codegen-test fixture would
permanently emit a warning despite being correct-by-design. With
it, each fixture annotates its strictness intentionally, the
warning suppresses, future-LLM reads the reason, future-iter
edits know whether to preserve or relax.
19b is dispatched next.
### Known debt
**Deeply-nested-match-on-sub-binder** (recorded by implementer in
the module doc): `match p { Ctor(_, t) => match t { Ctor(_, t2)
=> consume(t2) } }` would produce a *spurious* warning under the
current rule — `t.consume_count == 0` (matching is Borrow), so
the inner consume of `t2` doesn't propagate up to `p`'s view.
None of the 65 fixtures hit this shape; queued for follow-up if
real corpus surfaces it.
### Files / tests
- `crates/ailang-check/src/linearity.rs` — module-doc § 19a.1
rationale, `is_heap_type` helper, `any_sub_binder_consumed_for`
+ `pattern_has_consumed_heap_binder` walkers, ctor-table
threading through `check_module` / `check_fn`.
- Tests: `over_strict_mode_conservative_skips_match_on_param`
flipped to positive `over_strict_mode_fires_when_match_arm_uses_no_sub_binder`.
New `over_strict_mode_silent_when_match_arm_consumes_sub_binder`
and `over_strict_mode_fires_when_match_arm_binders_unused`.
`ailang-check` test count: 53 → 55. Workspace build/test green.