Skip to main content

lex_types/
rules.rs

1//! Rule-tagged messages for type errors (#306 slice 2).
2//!
3//! Every `TypeError` variant maps to a stable `rule_tag` (a kebab-
4//! case identifier) and a `rule_explanation` (plain-language
5//! description of what the rule enforces). LLM repair flows that
6//! reference the `rule_tag` get measurably better repair attempts
7//! because the model can cross-reference the rule across many
8//! prior examples.
9//!
10//! The tag is stable across releases — once shipped, a rule_tag
11//! never changes meaning. New rules get new tags; existing
12//! variants that split into more specific sub-rules will be
13//! handled by adding new sibling tags, not by repurposing existing
14//! ones.
15
16use crate::error::TypeError;
17
18/// Catalog entry for one rule.
19#[derive(Debug, Clone, Copy)]
20pub struct RuleInfo {
21    pub tag: &'static str,
22    pub explanation: &'static str,
23}
24
25impl TypeError {
26    /// Stable kebab-case identifier for this error variant. See
27    /// [`all_rules`] for the full catalog.
28    pub fn rule_tag(&self) -> &'static str {
29        match self {
30            TypeError::TypeMismatch { .. } => "type-mismatch",
31            TypeError::EffectRowMismatch { .. } => "effect-row-mismatch",
32            TypeError::UnknownIdentifier { .. } => "unknown-identifier",
33            TypeError::ArityMismatch { .. } => "arity-mismatch",
34            TypeError::NonExhaustiveMatch { .. } => "non-exhaustive-match",
35            TypeError::UnknownField { .. } => "unknown-field",
36            TypeError::DuplicateField { .. } => "duplicate-field",
37            TypeError::UnknownVariant { .. } => "unknown-variant",
38            TypeError::EffectNotDeclared { .. } => "effect-not-declared",
39            TypeError::InfiniteType { .. } => "infinite-type",
40            TypeError::AmbiguousType { .. } => "ambiguous-type",
41            TypeError::RecursiveTypeWithoutConstructor { .. } => "recursive-type-without-constructor",
42            TypeError::RefinementViolation { .. } => "refinement-violation",
43            TypeError::ExamplesOnEffectfulFn { .. } => "examples-on-effectful-fn",
44            TypeError::ExampleArityMismatch { .. } => "example-arity-mismatch",
45            TypeError::ExampleMismatch { .. } => "example-mismatch",
46        }
47    }
48
49    /// Plain-language description of what the rule enforces. Aimed
50    /// at LLM repair-flow prompts: short enough to inline in a
51    /// system message, specific enough to suggest the next move.
52    pub fn rule_explanation(&self) -> &'static str {
53        explanation_for_tag(self.rule_tag())
54    }
55}
56
57fn explanation_for_tag(tag: &str) -> &'static str {
58    match tag {
59        "type-mismatch" => "An expression's inferred type doesn't match what the surrounding context requires \
60(return type, let-binding annotation, function argument, operator operand, etc.). Fix by changing \
61the expression to produce the expected type, or by adjusting the declared/inferred expected type \
62to match.",
63        "effect-row-mismatch" => EFFECT_ROW_MISMATCH,
64        "unknown-identifier" => "A name referenced in scope is not declared. Either the binding is missing, \
65the name is misspelled, or an `import` is missing. Check for typos first; then verify the relevant \
66`let`, parameter, or top-level `fn` is in scope.",
67        "arity-mismatch" => "A call site supplies a different number of arguments than the function or \
68constructor accepts. Either add the missing arguments or remove the extras.",
69        "non-exhaustive-match" => "A `match` expression doesn't cover every case of its scrutinee's type. \
70Add the missing arms listed in the error, or add a `_` wildcard if catching the remainder is intended.",
71        "unknown-field" => "A record field access or literal references a field name that isn't part of \
72the record type. Verify spelling and that the type really has that field — check the type declaration.",
73        "duplicate-field" => "A record literal lists the same field name twice. Each field must appear \
74exactly once. Remove the duplicate or rename one of them.",
75        "unknown-variant" => "A constructor pattern or expression references a variant name that isn't \
76part of the union type. Verify spelling and that the variant exists on this union.",
77        "effect-not-declared" => "A function body invokes an effect (io, fs_read, net, …) that the \
78function's signature doesn't declare. Either add the effect to the function's `[effects]` annotation \
79or remove the call that produces it.",
80        "infinite-type" => "Inference would require a type to contain itself (e.g. `t = List<t>` with no \
81constructor). Add a nominal type wrapper or restructure the data so the recursion is mediated by a \
82named type.",
83        "ambiguous-type" => "Inference couldn't pick a single concrete type for an expression. Add a type \
84annotation to disambiguate.",
85        "recursive-type-without-constructor" => "A type alias references itself with no constructor in \
86between, so no value of the type can ever be built. Make the recursive position carry a constructor \
87(e.g. `Cons<T, List<T>> | Nil`).",
88        "refinement-violation" => "A literal argument provably violates a refinement-type predicate \
89(#209). Adjust the argument to satisfy the predicate, or relax the predicate at the function \
90signature.",
91        "examples-on-effectful-fn" => "A function with an `examples { ... }` block (#369) also \
92declares effects. Signature-level examples are pure-only in v1 — they must be deterministic so the \
93contract is reproducible. Either remove the effects from the signature, or remove the examples block \
94and rely on external tests.",
95        "example-arity-mismatch" => "A case inside an `examples { ... }` block (#369) supplies a \
96different number of arguments than the function declares. Match the call's argument count to the \
97function's parameter count.",
98        "example-mismatch" => "A case inside an `examples { ... }` block (#369) ran successfully \
99but the function body's actual return value differs from the declared `expected` value. Either \
100update the example to match the new behavior, or fix the body to produce the declared value.",
101        _ => "Unknown rule. The rule_tag may have been introduced after this Lex release.",
102    }
103}
104
105/// The full rule catalog, in stable order. Used by `lex docs --rules`
106/// and by tooling that wants to enumerate every supported rule
107/// (e.g. an LSP server building a code-actions registry).
108pub fn all_rules() -> &'static [RuleInfo] {
109    &[
110        RuleInfo { tag: "type-mismatch", explanation: TYPE_MISMATCH },
111        RuleInfo { tag: "effect-row-mismatch", explanation: EFFECT_ROW_MISMATCH },
112        RuleInfo { tag: "unknown-identifier", explanation: UNKNOWN_IDENT },
113        RuleInfo { tag: "arity-mismatch", explanation: ARITY_MISMATCH },
114        RuleInfo { tag: "non-exhaustive-match", explanation: NON_EXHAUSTIVE },
115        RuleInfo { tag: "unknown-field", explanation: UNKNOWN_FIELD },
116        RuleInfo { tag: "duplicate-field", explanation: DUPLICATE_FIELD },
117        RuleInfo { tag: "unknown-variant", explanation: UNKNOWN_VARIANT },
118        RuleInfo { tag: "effect-not-declared", explanation: EFFECT_NOT_DECLARED },
119        RuleInfo { tag: "infinite-type", explanation: INFINITE_TYPE },
120        RuleInfo { tag: "ambiguous-type", explanation: AMBIGUOUS_TYPE },
121        RuleInfo { tag: "recursive-type-without-constructor", explanation: RECURSIVE_NO_CTOR },
122        RuleInfo { tag: "refinement-violation", explanation: REFINEMENT_VIOLATION },
123        RuleInfo { tag: "examples-on-effectful-fn", explanation: EXAMPLES_ON_EFFECTFUL_FN },
124        RuleInfo { tag: "example-arity-mismatch", explanation: EXAMPLE_ARITY_MISMATCH },
125        RuleInfo { tag: "example-mismatch", explanation: EXAMPLE_MISMATCH },
126    ]
127}
128
129/// Static (rule_tag → suggested_transform) table for #306 slice 3.
130///
131/// When `Store::apply_operation_checked` rejects an op for a
132/// `TypeError`, the gate consults this table and pre-populates the
133/// `RepairHint` attestation's `suggested_transform` payload so the
134/// LLM repair flow (or a human reading `lex repair <op>`) has a
135/// concrete starting point. `None` means no static suggestion
136/// exists for this rule; the LLM-driven `lex repair --apply` path
137/// still works.
138///
139/// The returned shape is a JSON object with:
140/// - `kind_hint`: name of the typed transform most likely to fix it
141///   (`"ReplaceMatchArm"`, `"RenameLocal"`, `"InlineLet"`,
142///   `"ChangeEffectSig"`, `"ModifyBody"`).
143/// - `rule_tag`: echo of the rule that fired (for downstream
144///   correlation).
145/// - `summary`: one-sentence direction.
146/// - `details`: longer prose suitable for an LLM repair prompt.
147pub fn suggested_transform_for(rule_tag: &str) -> Option<serde_json::Value> {
148    let (kind_hint, summary, details) = match rule_tag {
149        "type-mismatch" => (
150            "ReplaceMatchArm",
151            "Replace the offending match arm (or expression) so its body produces the expected type.",
152            "When a function body's inferred type doesn't match its signature, the easiest \
153typed-transform fix is `ReplaceMatchArm` — rebuild whichever arm produces the wrong type so it \
154returns the expected one. For non-match expressions, the LLM-driven `lex repair --apply` flow \
155can rewrite the body via `ModifyBody`.",
156        ),
157        "unknown-identifier" => (
158            "RenameLocal",
159            "If the name is a typo, rename a similarly-spelled in-scope binding to match.",
160            "An `unknown-identifier` error is most often a typo. Search the function's lexical \
161scope for a binding whose name is a single edit away and apply `RenameLocal` to switch references. \
162If no nearby name exists, the missing binding probably needs a `let` or an `import` — fall back to \
163LLM-driven repair.",
164        ),
165        "non-exhaustive-match" => (
166            "ReplaceMatchArm",
167            "Add the missing match arms (or a `_` wildcard) covering the unhandled variants.",
168            "Use `ReplaceMatchArm` to append arms for the variants listed in the error's \
169`missing` field. If catching the remainder is intended, a single `_` wildcard arm suffices; \
170otherwise add one explicit arm per missing variant so the audit trail records the new semantics.",
171        ),
172        "effect-not-declared" => (
173            "ChangeEffectSig",
174            "Add the inferred effect to the function's `[effects]` declaration.",
175            "The function body invokes an effect that the signature doesn't declare. Either add \
176the effect to the signature via `ChangeEffectSig` (preferred — the effect is genuinely needed) or \
177remove the call that produces it via `ModifyBody` (preferred when the effect was unintentional).",
178        ),
179        "effect-row-mismatch" => (
180            "ModifyBody",
181            "Compare the expected and got rows: narrow the body if it grew an effect, or widen your annotation if a dependency widened its own fixed row (lex-lang#756).",
182            "Effect rows are invariant: the declared row must match exactly, so the intuitive repair \
183(adding the missing effect to the declared row) is wrong when that row is fixed by a record field or \
184other annotation — e.g. `Skill.handle`, `Tool.execute`. Use `ModifyBody` to remove or replace the \
185calls whose effects fall outside the declared row. Reach for `ChangeEffectSig` only when you own the \
186annotation and the extra effect is genuinely required.",
187        ),
188        "arity-mismatch" => (
189            "ModifyBody",
190            "Match the call site's argument count to the function's declared arity.",
191            "The number of arguments at the call site doesn't match the declared signature. \
192Add the missing arguments or remove the extras. No typed transform directly applies — \
193use the LLM-driven `lex repair --apply` flow with `ModifyBody` to rewrite the call site.",
194        ),
195        "unknown-field" => (
196            "ModifyBody",
197            "Verify the field spelling and the record type's declaration; rewrite the access.",
198            "The field name isn't part of the record type. Either correct the spelling or add \
199the missing field to the type declaration. Use the LLM-driven `lex repair --apply` flow with \
200`ModifyBody` to rewrite the field access once the correct name is known.",
201        ),
202        "ambiguous-type" => (
203            "ModifyBody",
204            "Add a type annotation at the ambiguous expression to disambiguate inference.",
205            "Inference couldn't pick a single concrete type. Add an explicit type annotation on \
206the offending `let` binding, function parameter, or function return type via `ModifyBody`. The \
207LLM-driven `lex repair --apply` flow can synthesize the annotation from the surrounding context.",
208        ),
209        "example-mismatch" => (
210            "ModifyBody",
211            "Reconcile the body with its declared example — fix one or the other.",
212            "An `examples { ... }` case ran but produced a value different from the declared \
213`expected`. Either the example is stale (LLM should rewrite the case via `ReplaceMatchArm` against \
214the `examples` block) or the body regressed (LLM should rewrite the body via `ModifyBody` to make \
215the case pass). The `case_index` field in the error identifies which case to act on.",
216        ),
217        _ => return None,
218    };
219    Some(serde_json::json!({
220        "kind_hint": kind_hint,
221        "rule_tag": rule_tag,
222        "summary": summary,
223        "details": details,
224    }))
225}
226
227// Constants keyed off the tag so `all_rules` and
228// `explanation_for_tag` produce identical strings without
229// duplicating the prose.
230const TYPE_MISMATCH: &str = "An expression's inferred type doesn't match what the surrounding context requires \
231(return type, let-binding annotation, function argument, operator operand, etc.). Fix by changing \
232the expression to produce the expected type, or by adjusting the declared/inferred expected type \
233to match.";
234const EFFECT_ROW_MISMATCH: &str = "Two function types failed to unify because their effect rows differ. \
235Effect rows unify by equality, not subtyping: a concrete row must match exactly — a superset or subset \
236is rejected. This most often surfaces on record-field closures (e.g. `Skill.handle`, `Tool.execute`), \
237whose declared effect row is fixed by the record type. Which fix is right depends on which side moved. \
238If your body grew an effect the row does not have, narrow the body. If the EXPECTED row is the larger \
239one, a library you depend on has widened its own fixed row — adding an effect to a published \
240record-field row is a breaking change for every dependent, and unpinned git dependencies deliver it \
241without any commit of yours (lex-lang#756). Then the fix is to match the library: widen your \
242annotation to the row it now declares. Read the expected/got pair before changing anything.";
243const UNKNOWN_IDENT: &str = "A name referenced in scope is not declared. Either the binding is missing, \
244the name is misspelled, or an `import` is missing. Check for typos first; then verify the relevant \
245`let`, parameter, or top-level `fn` is in scope.";
246const ARITY_MISMATCH: &str = "A call site supplies a different number of arguments than the function or \
247constructor accepts. Either add the missing arguments or remove the extras.";
248const NON_EXHAUSTIVE: &str = "A `match` expression doesn't cover every case of its scrutinee's type. \
249Add the missing arms listed in the error, or add a `_` wildcard if catching the remainder is intended.";
250const UNKNOWN_FIELD: &str = "A record field access or literal references a field name that isn't part of \
251the record type. Verify spelling and that the type really has that field — check the type declaration.";
252const DUPLICATE_FIELD: &str = "A record literal lists the same field name twice. Each field must appear \
253exactly once. Remove the duplicate or rename one of them.";
254const UNKNOWN_VARIANT: &str = "A constructor pattern or expression references a variant name that isn't \
255part of the union type. Verify spelling and that the variant exists on this union.";
256const EFFECT_NOT_DECLARED: &str = "A function body invokes an effect (io, fs_read, net, …) that the \
257function's signature doesn't declare. Either add the effect to the function's `[effects]` annotation \
258or remove the call that produces it.";
259const INFINITE_TYPE: &str = "Inference would require a type to contain itself (e.g. `t = List<t>` with no \
260constructor). Add a nominal type wrapper or restructure the data so the recursion is mediated by a \
261named type.";
262const AMBIGUOUS_TYPE: &str = "Inference couldn't pick a single concrete type for an expression. Add a type \
263annotation to disambiguate.";
264const RECURSIVE_NO_CTOR: &str = "A type alias references itself with no constructor in \
265between, so no value of the type can ever be built. Make the recursive position carry a constructor \
266(e.g. `Cons<T, List<T>> | Nil`).";
267const REFINEMENT_VIOLATION: &str = "A literal argument provably violates a refinement-type predicate \
268(#209). Adjust the argument to satisfy the predicate, or relax the predicate at the function \
269signature.";
270const EXAMPLES_ON_EFFECTFUL_FN: &str = "A function with an `examples { ... }` block (#369) also \
271declares effects. Signature-level examples are pure-only in v1 — they must be deterministic so the \
272contract is reproducible. Either remove the effects from the signature, or remove the examples block \
273and rely on external tests.";
274const EXAMPLE_ARITY_MISMATCH: &str = "A case inside an `examples { ... }` block (#369) supplies a \
275different number of arguments than the function declares. Match the call's argument count to the \
276function's parameter count.";
277const EXAMPLE_MISMATCH: &str = "A case inside an `examples { ... }` block (#369) ran successfully \
278but the function body's actual return value differs from the declared `expected` value. Either \
279update the example to match the new behavior, or fix the body to produce the declared value.";
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn every_variant_has_a_distinct_tag() {
287        // Tags are stable identifiers; collisions would be a bug
288        // and would silently merge two rule explanations.
289        let tags: Vec<&str> = all_rules().iter().map(|r| r.tag).collect();
290        let unique: std::collections::BTreeSet<&str> = tags.iter().copied().collect();
291        assert_eq!(unique.len(), tags.len(), "rule tags must be unique: {tags:?}");
292    }
293
294    #[test]
295    fn every_variant_has_a_nonempty_explanation() {
296        for rule in all_rules() {
297            assert!(!rule.explanation.is_empty(), "rule `{}` lacks an explanation", rule.tag);
298            assert!(
299                rule.explanation.len() > 40,
300                "rule `{}` explanation is too short to be useful for LLM repair",
301                rule.tag
302            );
303        }
304    }
305
306    #[test]
307    fn type_error_methods_match_catalog() {
308        // Pick a representative variant per rule and check the
309        // method round-trips against `all_rules`.
310        let cases: Vec<TypeError> = vec![
311            TypeError::TypeMismatch {
312                at_node: "n_0".into(),
313                expected: "Int".into(),
314                got: "Str".into(),
315                context: vec![],
316            },
317            TypeError::EffectRowMismatch {
318                at_node: "n_0".into(),
319                expected: "[io]".into(),
320                got: "[net]".into(),
321                context: vec![],
322            },
323            TypeError::UnknownIdentifier { at_node: "n_0".into(), name: "x".into() },
324            TypeError::ArityMismatch { at_node: "n_0".into(), expected: 1, got: 2 },
325            TypeError::NonExhaustiveMatch { at_node: "n_0".into(), missing: vec!["None".into()] },
326            TypeError::UnknownField {
327                at_node: "n_0".into(),
328                record_type: "User".into(),
329                field: "ag".into(),
330            },
331            TypeError::DuplicateField { at_node: "n_0".into(), field: "name".into() },
332            TypeError::UnknownVariant { at_node: "n_0".into(), constructor: "Nada".into() },
333            TypeError::EffectNotDeclared { at_node: "n_0".into(), effect: "io".into() },
334            TypeError::InfiniteType { at_node: "n_0".into() },
335            TypeError::AmbiguousType { at_node: "n_0".into() },
336            TypeError::RecursiveTypeWithoutConstructor {
337                at_node: "n_0".into(),
338                name: "Bad".into(),
339            },
340            TypeError::RefinementViolation {
341                at_node: "n_0".into(),
342                fn_name: "f".into(),
343                param_index: 0,
344                binding: "x".into(),
345                reason: "x > 0".into(),
346            },
347            TypeError::ExamplesOnEffectfulFn {
348                at_node: "n_0".into(),
349                fn_name: "f".into(),
350            },
351            TypeError::ExampleArityMismatch {
352                at_node: "n_0".into(),
353                fn_name: "f".into(),
354                case_index: 0,
355                expected: 2,
356                got: 1,
357            },
358            TypeError::ExampleMismatch {
359                at_node: "n_0".into(),
360                fn_name: "f".into(),
361                case_index: 0,
362                expected: "1".into(),
363                got: "2".into(),
364            },
365        ];
366        let catalog: std::collections::BTreeMap<&str, &str> =
367            all_rules().iter().map(|r| (r.tag, r.explanation)).collect();
368        assert_eq!(cases.len(), catalog.len(), "every variant must be covered");
369        for e in &cases {
370            let tag = e.rule_tag();
371            let expl = catalog.get(tag).unwrap_or_else(|| panic!("tag `{tag}` not in catalog"));
372            assert_eq!(e.rule_explanation(), *expl, "tag/explanation mismatch on {tag}");
373        }
374    }
375
376    #[test]
377    fn suggested_transform_covers_at_least_five_rules() {
378        // #306 slice 3 AC: ≥5 rule_tags have a non-None
379        // suggested_transform. Catches accidental removal.
380        let mut covered = 0;
381        for rule in all_rules() {
382            if suggested_transform_for(rule.tag).is_some() {
383                covered += 1;
384            }
385        }
386        assert!(
387            covered >= 5,
388            "suggested_transform must cover ≥5 rule_tags; got {covered}"
389        );
390    }
391
392    #[test]
393    fn suggested_transform_shape_is_consistent() {
394        // Every non-None suggestion must carry the four fields
395        // documented in `suggested_transform_for`.
396        for rule in all_rules() {
397            let Some(s) = suggested_transform_for(rule.tag) else { continue };
398            for field in ["kind_hint", "rule_tag", "summary", "details"] {
399                assert!(
400                    s.get(field).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty()),
401                    "rule `{}` suggestion missing/empty `{field}`: {s}",
402                    rule.tag
403                );
404            }
405            assert_eq!(
406                s.get("rule_tag").and_then(|v| v.as_str()),
407                Some(rule.tag),
408                "suggestion's rule_tag must echo the input tag"
409            );
410        }
411    }
412
413    #[test]
414    fn unknown_rule_tag_returns_none() {
415        assert!(suggested_transform_for("does-not-exist").is_none());
416    }
417}