check: over-strict-mode lint (iter 19a)

When a fn-param is annotated (own T) but the body never consumes
it (consume_count == 0) and never destructures it via match, the
linearity pass now emits a Severity::Warning diagnostic with a
form-A-rendered (borrow T) rewrite as suggested_rewrite. Pure
advisory, Decision 10 unchanged.

First Warning-severity diagnostic the typechecker emits; three
CLI exit paths gated on Error-only.

JOURNAL: design entry + iter 19a shipping entry.
This commit is contained in:
2026-05-08 17:56:58 +02:00
parent caefcf996a
commit 41804a39fb
6 changed files with 572 additions and 13 deletions
+150
View File
@@ -8591,3 +8591,153 @@ implementation" — it is the literal RED-step that the discipline
prescribes. The three fixtures shipped here are exactly that:
each pins one of the three carve-out sites, observable via the
RC stats line, kept as regression. JOURNAL queue: empty.
## 2026-05-08 — Design: explicit annotations stay mandatory; `over-strict-mode` diagnostic
User pushback on the queue-driven dispatch: the orchestrator
floated "uniqueness inference replaces fn-signature mode
annotations" as a candidate direction. Re-read of DESIGN.md
§ "Decision 10" (lines 685795) shows that pitch was a
**Decision-10 reversal**, not an iter within it:
> *"AILang makes it mandatory because the LLM author can carry
> the cognitive cost trivially, and the compiler gains a precise
> contract at every call site instead of a probabilistic guess."*
Three reasons mandatory annotations are not redundancy in the
harmful sense:
1. **Inference picks weakest-supporting; annotation states
intent.** These often coincide today but are conceptually
different. The annotation captures what the author committed
to, not what the current body needs (e.g. `(own T)` reserved
for a planned in-place mutation that hasn't landed).
2. **Annotation is a drift-bremse.** Body change that flips
the inferred mode → caller-side breakage at remote sites.
Annotation enforces the local-conflict-error pattern instead.
3. **Forcing function specific to LLM authoring.** Without
mandatory annotation, an LLM never has to commit to ownership
intent before writing the body — the contract becomes a
by-product of local code choice. With it, the contract is a
first-order variable the body must satisfy.
The "redundancy" between annotation and body is therefore the
feature, not the bug — analogous to test code redundantly
restating implementation behaviour, where the redundancy is what
catches drift.
### What survives of the original pitch
One narrow but real observation: today, when the author writes
`(own T)` and the body would compile under `(borrow T)`, the
typechecker silently accepts. No warning, no diagnostic. The
runtime cost is real (one inc/dec pair per call, plus potential
rec-cascade at fn return), but invisible.
The Rust analogue is `clippy::needless_pass_by_value`: an
informational lint with a suppression mechanism. AILang sharpens
this to "suppression carries a mandatory reason" — consistent
with the explicit-intent philosophy.
### Iter scope
Two iters, dispatched separately so 19a's diagnostic frequency
on real code informs whether 19b's escape hatch is needed at all:
- **Iter 19a — `over-strict-mode` diagnostic.** Linearity pass
detects: `param_modes[i] == Own` + `consume_count(p) == 0` +
no consume of any sub-binder of p in the body. Emits
`Severity::Warning` (the first warning-level diagnostic the
typechecker has emitted; reserves no longer reserved). Carries
a `suggested_rewrite` showing the relaxed `(borrow T)`
signature. No schema change; pure detection.
- **Iter 19b — `mode-strict-because` suppression** (deferred
pending 19a's signal). Optional schema field on fn defs:
`suppress: [{code: String, because: String}]`. `because` is
non-empty (schema-validation error otherwise). The suppress
mechanism is generic across diagnostic codes but the only
consumer initially is `over-strict-mode`.
### Why split
19a alone is shippable: users who see the warning either accept
the rewrite or live with the noise on intentional-strict fns.
19b is only worth building if real code surfaces enough
intentional-strict cases to make the noise unbearable. This lets
the data drive whether 19b ships at all.
### What this is NOT
Not a relaxation of Decision 10. Annotations stay mandatory.
Authors cannot omit `param_modes` / `ret_mode`; the compiler
will not infer them. The diagnostic is purely advisory: "you
wrote this, here's a tighter alternative." If suppressed (19b),
the annotation stays exactly as authored.
## 2026-05-08 — Iter 19a: `over-strict-mode` diagnostic shipped
The diagnostic-side of the design entry above. Linearity pass
gained a post-walk loop over `Own`-annotated params; uniqueness
side-table provides `consume_count`. When `consume_count == 0`
*and* the body never destructures the param via match, the lint
fires with a form-A-rendered fn-type rewrite as `suggested_rewrite`.
### Conservative cut
The precise rule is "no consume of any sub-binder of `p`". The
shipped check approximates with "no `match` whose scrutinee is
`pname`" — sound (never false-positive) but incomplete (misses
match-on-p-without-sub-consume). Documented in `linearity.rs`'s
module doc and pinned by the test
`over_strict_mode_conservative_skips_match_on_param`. Acceptable
because the lint is purely advisory: a missed warning costs
nothing, a spurious warning would actively mislead. The precise
variant is queued for a follow-up if real-corpus signal warrants.
### CLI side-effect
This is the first `Severity::Warning` diagnostic the typechecker
emits. Three CLI exit paths (`Cmd::Check` non-JSON, `Cmd::EmitIr`,
`build_to`) previously aborted on any non-empty diagnostic list;
now they only abort when at least one diagnostic is `Severity::Error`.
The `--json` path was already correct (returns the list, doesn't
exit). No observable behaviour change for any pre-19a input —
nothing emitted Warning before this iter — but the gate is now
in place for future warning-level lints.
### Tests
Four new tests in `linearity.rs::tests`:
- `over_strict_mode_fires_when_param_only_borrowed` — positive.
- `over_strict_mode_silent_when_body_consumes_param` — negative.
- `over_strict_mode_silent_when_param_is_borrow` — negative.
- `over_strict_mode_conservative_skips_match_on_param` — pins the
conservatism; flips to a positive assertion when the precise
variant lands.
`ailang-check` test count: 49 → 53. e2e unchanged at 69. Workspace
build + test green.
### Files touched
- `crates/ailang-check/src/diagnostic.rs` — registered
`over-strict-mode` code, refreshed `Severity::Warning` doc,
added `Diagnostic::warning` ctor.
- `crates/ailang-check/src/linearity.rs` — module-doc § 19a, the
post-walk loop, helpers (`body_matches_on`,
`scrutinee_matches_param`, `make_over_strict_mode`,
`relax_param_to_borrow`), `check_fn` signature gained a
`&UniquenessTable` arg.
- `crates/ailang-surface/src/print.rs` — new
`pub fn type_to_form_a(&Type) -> String` (re-exported from `lib.rs`).
- `crates/ail/src/main.rs` — three exit-on-Error gates.
### What stays queued
- Iter 19b (`mode-strict-because` suppression): waits for
real-corpus signal on whether intentional-strict fns are
common enough to warrant the schema field.
- Precise sub-binder analysis for the lint: waits for evidence
that the conservative cut misses real cases.