Skip to main content

lemma/
quality.rs

1//! Structural quality analysis over loaded specs.
2//!
3//! Advisory only: never affects planning or evaluation. Distinct from [`Error`] /
4//! Veto / panic.
5
6use crate::error::EngineErrorSource;
7use crate::literals::{BooleanValue, Value};
8use crate::parsing::ast::{
9    ComparisonComputation, DataValue, Expression, ExpressionKind, LemmaData, LemmaRule, LemmaSpec,
10    NegationType, ParentType, PrimitiveKind, Span, TypeConstraintCommand,
11};
12use crate::parsing::source::{Source, SourceType};
13use crate::Engine;
14use serde::{Deserialize, Serialize};
15use std::fmt;
16
17/// One structural quality Recommendation from [`Engine::quality`].
18///
19/// Wire JSON uses `source` ([`EngineErrorSource`]); runtime keeps [`Source`] as
20/// `source_location` for Display and analysis.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Recommendation {
23    /// Advisory prose only. Does not encode which temporal slice was analyzed.
24    pub message: String,
25    pub repository: Option<String>,
26    pub spec: String,
27    pub effective_from: Option<crate::parsing::ast::DateTimeValue>,
28    pub source_location: Source,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32struct RecommendationWire {
33    message: String,
34    repository: Option<String>,
35    spec: String,
36    effective_from: Option<crate::parsing::ast::DateTimeValue>,
37    source: EngineErrorSource,
38}
39
40impl From<&Recommendation> for RecommendationWire {
41    fn from(r: &Recommendation) -> Self {
42        Self {
43            message: r.message.clone(),
44            repository: r.repository.clone(),
45            spec: r.spec.clone(),
46            effective_from: r.effective_from.clone(),
47            source: EngineErrorSource::from(&r.source_location),
48        }
49    }
50}
51
52impl From<RecommendationWire> for Recommendation {
53    fn from(w: RecommendationWire) -> Self {
54        let source_type =
55            SourceType::from_binding_label(&w.source.attribute).unwrap_or(SourceType::Volatile);
56        let end = w.source.length;
57        Self {
58            message: w.message,
59            repository: w.repository,
60            spec: w.spec,
61            effective_from: w.effective_from,
62            source_location: Source::new(
63                source_type,
64                Span {
65                    start: 0,
66                    end,
67                    line: w.source.line,
68                    col: w.source.column,
69                },
70            ),
71        }
72    }
73}
74
75impl Serialize for Recommendation {
76    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
77        RecommendationWire::from(self).serialize(serializer)
78    }
79}
80
81impl<'de> Deserialize<'de> for Recommendation {
82    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
83        RecommendationWire::deserialize(deserializer).map(Self::from)
84    }
85}
86
87impl fmt::Display for Recommendation {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        write!(f, "In spec '{}'", self.spec)?;
90        match &self.effective_from {
91            Some(dt) => write!(f, " (effective from {dt})")?,
92            None => write!(f, " (effective from beginning)")?,
93        }
94        write!(f, ": {}", self.message)?;
95        write!(
96            f,
97            " at {}:{}:{}",
98            self.source_location.source_type,
99            self.source_location.span.line,
100            self.source_location.span.col
101        )
102    }
103}
104
105impl Engine {
106    /// Structural quality Recommendations across all loaded specs and their relationships.
107    ///
108    /// Advisory only: never affects planning or evaluation. Skips dependency
109    /// repositories (embedded stdlib and `@owner/repo` imports).
110    #[must_use]
111    pub fn quality(&self) -> Vec<Recommendation> {
112        let mut out = Vec::new();
113        for (repository, inner) in self.context.repositories().iter() {
114            if repository.dependency.is_some() {
115                continue;
116            }
117            let repo_name = repository.name.clone();
118            for (_, spec_set) in inner.iter() {
119                for spec in spec_set.iter_specs() {
120                    analyze_spec(repo_name.clone(), spec, &mut out);
121                }
122            }
123        }
124        out.sort_by(|a, b| {
125            (
126                a.repository.as_deref().unwrap_or(""),
127                a.spec.as_str(),
128                a.source_location.span.line,
129                a.source_location.span.col,
130                a.source_location.span.start,
131            )
132                .cmp(&(
133                    b.repository.as_deref().unwrap_or(""),
134                    b.spec.as_str(),
135                    b.source_location.span.line,
136                    b.source_location.span.col,
137                    b.source_location.span.start,
138                ))
139        });
140        out
141    }
142}
143
144fn analyze_spec(repository: Option<String>, spec: &LemmaSpec, out: &mut Vec<Recommendation>) {
145    for data in &spec.data {
146        analyze_data(repository.clone(), spec, data, out);
147    }
148    for rule in &spec.rules {
149        analyze_rule(repository.clone(), spec, rule, out);
150    }
151}
152
153fn analyze_import(
154    repository: Option<String>,
155    spec: &LemmaSpec,
156    bindings: &[crate::parsing::ast::UsesBinding],
157    out: &mut Vec<Recommendation>,
158) {
159    for binding in bindings {
160        if !binding.deprecated_standalone_with {
161            continue;
162        }
163        out.push(Recommendation {
164            message: "Standalone `with alias.field: …` is deprecated; nest under the matching `uses` line as `  -> with field: …`.".to_string(),
165            repository: repository.clone(),
166            spec: spec.name.clone(),
167            effective_from: spec.effective_from.to_option(),
168            source_location: binding.source_location.clone(),
169        });
170    }
171}
172
173fn analyze_data(
174    repository: Option<String>,
175    spec: &LemmaSpec,
176    data: &LemmaData,
177    out: &mut Vec<Recommendation>,
178) {
179    if let DataValue::Import { bindings, .. } = &data.value {
180        analyze_import(repository.clone(), spec, bindings, out);
181        return;
182    }
183
184    let DataValue::Definition {
185        base,
186        constraints,
187        value: _,
188    } = &data.value
189    else {
190        return;
191    };
192
193    let name = data.reference.name.clone();
194    let constraints = constraints.as_deref().unwrap_or(&[]);
195    let has_help = constraints
196        .iter()
197        .any(|row| matches!(row.command, TypeConstraintCommand::Help));
198    let has_option = constraints.iter().any(|row| {
199        matches!(
200            row.command,
201            TypeConstraintCommand::Option | TypeConstraintCommand::Options
202        )
203    });
204    let has_minimum = constraints
205        .iter()
206        .any(|row| matches!(row.command, TypeConstraintCommand::Minimum));
207    let has_maximum = constraints
208        .iter()
209        .any(|row| matches!(row.command, TypeConstraintCommand::Maximum));
210
211    for row in constraints {
212        if !row.deprecated_without_colon {
213            continue;
214        }
215        out.push(Recommendation {
216            message: "`-> unit name value` is deprecated; use `-> unit name: value`.".to_string(),
217            repository: repository.clone(),
218            spec: spec.name.clone(),
219            effective_from: spec.effective_from.to_option(),
220            source_location: row.source_location.clone(),
221        });
222    }
223
224    if !has_help {
225        out.push(Recommendation {
226            message: format!(
227                "`{name}` has no `-> help`. Consider adding a message to help users understand this data."
228            ),
229            repository: repository.clone(),
230            spec: spec.name.clone(),
231            effective_from: spec.effective_from.to_option(),
232            source_location: data.source_location.clone(),
233        });
234    }
235
236    if is_primitive_bounded_quantity(base.as_ref()) && (!has_minimum || !has_maximum) {
237        let gap = match (has_minimum, has_maximum) {
238            (false, false) => "no `-> minimum` or `-> maximum`",
239            (true, false) => "no `-> maximum`",
240            (false, true) => "no `-> minimum`",
241            (true, true) => unreachable!("BUG: both bounds present but entered missing-bounds arm"),
242        };
243        out.push(Recommendation {
244            message: format!(
245                "`{name}` has {gap}. Consider adding bounds so out-of-range values are rejected."
246            ),
247            repository: repository.clone(),
248            spec: spec.name.clone(),
249            effective_from: spec.effective_from.to_option(),
250            source_location: data.source_location.clone(),
251        });
252    }
253
254    if is_primitive_text(base.as_ref()) && !has_option {
255        out.push(Recommendation {
256            message: format!(
257                "`{name}` accepts any text. Adding `-> option` values allows forms and APIs to offer choices."
258            ),
259            repository,
260            spec: spec.name.clone(),
261            effective_from: spec.effective_from.to_option(),
262            source_location: data.source_location.clone(),
263        });
264    }
265}
266
267fn unwrap_parent_type(base: Option<&ParentType>) -> Option<&ParentType> {
268    match base {
269        Some(ParentType::Qualified { inner, .. } | ParentType::Ranged { inner }) => {
270            unwrap_parent_type(Some(inner.as_ref()))
271        }
272        other => other,
273    }
274}
275
276fn is_primitive_text(base: Option<&ParentType>) -> bool {
277    matches!(
278        unwrap_parent_type(base),
279        Some(ParentType::Primitive {
280            primitive: PrimitiveKind::Text
281        })
282    )
283}
284
285fn is_primitive_bounded_quantity(base: Option<&ParentType>) -> bool {
286    matches!(
287        unwrap_parent_type(base),
288        Some(ParentType::Primitive {
289            primitive: PrimitiveKind::Number | PrimitiveKind::Measure | PrimitiveKind::Ratio
290        })
291    )
292}
293
294fn analyze_rule(
295    repository: Option<String>,
296    spec: &LemmaSpec,
297    rule: &LemmaRule,
298    out: &mut Vec<Recommendation>,
299) {
300    walk_expr_for_ambiguous_and(repository.clone(), spec, rule, &rule.expression, out);
301    for unless in &rule.unless_clauses {
302        walk_expr_for_ambiguous_and(repository.clone(), spec, rule, &unless.condition, out);
303        walk_expr_for_ambiguous_and(repository.clone(), spec, rule, &unless.result, out);
304    }
305
306    analyze_redundant_boolean_default_unless(repository.clone(), spec, rule, out);
307
308    if is_boolean_literal(&rule.expression)
309        && !rule.unless_clauses.is_empty()
310        && rule
311            .unless_clauses
312            .iter()
313            .all(|u| matches!(u.result.kind, ExpressionKind::Veto(_)))
314    {
315        out.push(Recommendation {
316            message: format!(
317                "`{}` treats a yes/no outcome as veto. Consider `false` or `no` when denying — veto means there is no answer, and it blocks every rule that depends on this one.",
318                rule.name
319            ),
320            repository,
321            spec: spec.name.clone(),
322            effective_from: spec.effective_from.to_option(),
323            source_location: rule.source_location.clone(),
324        });
325    }
326}
327
328fn boolean_literal_truth(expr: &Expression) -> Option<bool> {
329    match &expr.kind {
330        ExpressionKind::Literal(Value::Boolean(b)) => Some(bool::from(*b)),
331        _ => None,
332    }
333}
334
335fn analyze_redundant_boolean_default_unless(
336    repository: Option<String>,
337    spec: &LemmaSpec,
338    rule: &LemmaRule,
339    out: &mut Vec<Recommendation>,
340) {
341    if rule.unless_clauses.len() != 1 {
342        return;
343    }
344    let unless = &rule.unless_clauses[0];
345    if boolean_literal_truth(&rule.expression) != Some(false) {
346        return;
347    }
348    if boolean_literal_truth(&unless.result) != Some(true) {
349        return;
350    }
351    let condition = format!("{}", unless.condition);
352    out.push(Recommendation {
353        message: format!(
354            "`{}` uses `no` (or `false`) with a single `unless … then yes`. Equivalent to `{condition}`; use a direct expression instead.",
355            rule.name
356        ),
357        repository,
358        spec: spec.name.clone(),
359        effective_from: spec.effective_from.to_option(),
360        source_location: rule.source_location.clone(),
361    });
362}
363
364fn is_boolean_literal(expr: &Expression) -> bool {
365    boolean_literal_truth(expr).is_some()
366}
367
368/// Conjunct shape for ambiguous `and` detection (AST only).
369#[derive(Debug, Clone, Copy, PartialEq, Eq)]
370enum BoolConjunct {
371    Bare,
372    NotRef,
373    IsFalse,
374    Other,
375}
376
377fn classify_bool_conjunct(expr: &Expression) -> BoolConjunct {
378    match &expr.kind {
379        ExpressionKind::Reference(_) => BoolConjunct::Bare,
380        ExpressionKind::LogicalNegation(inner, NegationType::Not)
381            if matches!(inner.kind, ExpressionKind::Reference(_)) =>
382        {
383            BoolConjunct::NotRef
384        }
385        ExpressionKind::Comparison(left, ComparisonComputation::Is, right)
386            if matches!(left.kind, ExpressionKind::Reference(_))
387                && matches!(
388                    right.kind,
389                    ExpressionKind::Literal(Value::Boolean(BooleanValue::False | BooleanValue::No))
390                ) =>
391        {
392            BoolConjunct::IsFalse
393        }
394        _ => BoolConjunct::Other,
395    }
396}
397
398fn is_ambiguous_logical_and(left: &Expression, right: &Expression) -> bool {
399    matches!(
400        (classify_bool_conjunct(left), classify_bool_conjunct(right)),
401        (BoolConjunct::Bare, BoolConjunct::NotRef)
402            | (BoolConjunct::NotRef, BoolConjunct::Bare)
403            | (BoolConjunct::Bare, BoolConjunct::IsFalse)
404            | (BoolConjunct::IsFalse, BoolConjunct::Bare)
405    )
406}
407
408const AMBIGUOUS_AND_MESSAGE: &str = "Boolean `and` mixes a bare name with `not` / `is false`. Readers misread scope. Prefer parallel probes (`not x and y is true`, or `x is false and y is true`) or parentheses.";
409
410fn walk_expr_for_ambiguous_and(
411    repository: Option<String>,
412    spec: &LemmaSpec,
413    rule: &LemmaRule,
414    expr: &Expression,
415    out: &mut Vec<Recommendation>,
416) {
417    match &expr.kind {
418        ExpressionKind::LogicalAnd(left, right) => {
419            if is_ambiguous_logical_and(left, right) {
420                out.push(Recommendation {
421                    message: AMBIGUOUS_AND_MESSAGE.to_string(),
422                    repository: repository.clone(),
423                    spec: spec.name.clone(),
424                    effective_from: spec.effective_from.to_option(),
425                    source_location: expr
426                        .source_location
427                        .clone()
428                        .unwrap_or_else(|| rule.source_location.clone()),
429                });
430            }
431            walk_expr_for_ambiguous_and(repository.clone(), spec, rule, left, out);
432            walk_expr_for_ambiguous_and(repository, spec, rule, right, out);
433        }
434        ExpressionKind::DateRelative(_, inner)
435        | ExpressionKind::PastFutureRange(_, inner)
436        | ExpressionKind::UnitConversion(inner, _)
437        | ExpressionKind::LogicalNegation(inner, _)
438        | ExpressionKind::MathematicalComputation(_, inner)
439        | ExpressionKind::RangeBound(_, inner)
440        | ExpressionKind::ResultIsVeto(inner) => {
441            walk_expr_for_ambiguous_and(repository, spec, rule, inner, out);
442        }
443        ExpressionKind::DateCalendar(_, _, inner) => {
444            walk_expr_for_ambiguous_and(repository, spec, rule, inner, out);
445        }
446        ExpressionKind::RangeLiteral(left, right)
447        | ExpressionKind::RangeContainment(left, right)
448        | ExpressionKind::Arithmetic(left, _, right)
449        | ExpressionKind::Comparison(left, _, right) => {
450            walk_expr_for_ambiguous_and(repository.clone(), spec, rule, left, out);
451            walk_expr_for_ambiguous_and(repository, spec, rule, right, out);
452        }
453        ExpressionKind::Literal(_)
454        | ExpressionKind::Reference(_)
455        | ExpressionKind::Now
456        | ExpressionKind::Veto(_) => {}
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use crate::parsing::source::SourceType;
464
465    fn load(code: &str) -> Engine {
466        let mut engine = Engine::new();
467        engine
468            .load([(
469                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
470                code.to_string(),
471            )])
472            .expect("BUG: test source must load");
473        engine
474    }
475
476    fn effective_display(r: &Recommendation) -> Option<String> {
477        r.effective_from.as_ref().map(|d| d.to_string())
478    }
479
480    #[test]
481    fn origin_without_commentary_does_not_flag_optional_gaps() {
482        let engine = load("spec pricing\ndata x: number\nrule y: x\n");
483        let recs = engine.quality();
484        assert!(
485            recs.iter().all(|r| {
486                !r.message.contains("commentary")
487                    && !r.message.contains("effective date")
488                    && !r.message.contains("-> suggest")
489            }),
490            "optional gaps must not be recommended: {recs:?}"
491        );
492        let help = recs
493            .iter()
494            .find(|r| r.message.contains("no `-> help`") && r.message.contains("x"))
495            .expect("missing help");
496        assert!(
497            help.message.contains("Consider adding a message"),
498            "got: {}",
499            help.message
500        );
501        assert_eq!(help.spec, "pricing");
502        assert_eq!(help.effective_from, None);
503    }
504
505    #[test]
506    fn clean_spec_has_no_recommendations() {
507        let engine = load(
508            r#"spec pricing 2026-01-01
509"""
510Bulk pricing.
511"""
512
513data qty: number
514  -> minimum 0
515  -> maximum 1000000
516  -> help "Order quantity."
517
518rule total: qty
519"#,
520        );
521        assert!(engine.quality().is_empty(), "got: {:?}", engine.quality());
522    }
523
524    #[test]
525    fn deprecated_standalone_with_emits_quality_recommendation() {
526        let engine = load(
527            r#"spec inner
528data x: number
529
530spec outer
531uses i: inner
532with i.x: 42
533rule r: i.x
534"#,
535        );
536        let recs = engine.quality();
537        let hit = recs
538            .iter()
539            .find(|r| r.message.contains("deprecated") && r.spec == "outer")
540            .expect("deprecated standalone with must produce quality recommendation");
541        assert!(hit.message.contains("-> with"), "got: {}", hit.message);
542        assert_eq!(hit.source_location.span.line, 6);
543    }
544
545    #[test]
546    fn block_uses_binding_has_no_deprecated_recommendation() {
547        let engine = load(
548            r#"spec inner
549data x: number
550
551spec outer
552uses i: inner
553  -> with x: 42
554rule r: i.x
555"#,
556        );
557        assert!(
558            engine
559                .quality()
560                .iter()
561                .all(|r| !r.message.contains("deprecated")),
562            "block syntax must not flag deprecated: {:?}",
563            engine.quality()
564        );
565    }
566
567    #[test]
568    fn number_without_bounds() {
569        let engine = load(
570            r#"spec pricing 2026-01-01
571"""
572x
573"""
574
575data qty: number
576  -> help "Order quantity."
577rule total: qty
578"#,
579        );
580        let hit = engine
581            .quality()
582            .into_iter()
583            .find(|r| {
584                r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("qty")
585            })
586            .expect("missing bounds");
587        assert!(
588            hit.message.contains("Consider adding bounds"),
589            "got: {}",
590            hit.message
591        );
592    }
593
594    #[test]
595    fn number_with_only_minimum_still_flagged() {
596        let engine = load(
597            r#"spec pricing 2026-01-01
598"""
599x
600"""
601
602data qty: number
603  -> minimum 0
604  -> help "Order quantity."
605rule total: qty
606"#,
607        );
608        assert!(
609            engine.quality().iter().any(|r| {
610                r.message.contains("no `-> maximum`")
611                    && !r.message.contains("no `-> minimum` or")
612                    && r.message.contains("qty")
613            }),
614            "only minimum must flag missing maximum: {:?}",
615            engine.quality()
616        );
617    }
618
619    #[test]
620    fn number_with_only_maximum_still_flagged() {
621        let engine = load(
622            r#"spec pricing 2026-01-01
623"""
624x
625"""
626
627data qty: number
628  -> maximum 100
629  -> help "Order quantity."
630rule total: qty
631"#,
632        );
633        assert!(
634            engine.quality().iter().any(|r| {
635                r.message.contains("no `-> minimum`")
636                    && !r.message.contains("or `-> maximum`")
637                    && r.message.contains("qty")
638            }),
639            "only maximum must flag missing minimum: {:?}",
640            engine.quality()
641        );
642    }
643
644    #[test]
645    fn number_with_min_and_max_clean() {
646        let engine = load(
647            r#"spec pricing 2026-01-01
648"""
649x
650"""
651
652data qty: number
653  -> minimum 0
654  -> maximum 100
655  -> help "Order quantity."
656rule total: qty
657"#,
658        );
659        assert!(
660            !engine
661                .quality()
662                .iter()
663                .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
664            "min+max must not flag bounds: {:?}",
665            engine.quality()
666        );
667    }
668
669    #[test]
670    fn measure_and_ratio_without_bounds() {
671        let engine = load(
672            r#"spec pricing 2026-01-01
673"""
674x
675"""
676
677data price: measure
678  -> unit eur: 1
679  -> help "Unit price."
680data discount: ratio
681  -> help "Discount rate."
682rule total: price
683rule rate: discount
684"#,
685        );
686        let recs = engine.quality();
687        assert!(
688            recs.iter().any(|r| {
689                r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("price")
690            }),
691            "measure must flag: {recs:?}"
692        );
693        assert!(
694            recs.iter().any(|r| {
695                r.message.contains("no `-> minimum` or `-> maximum`")
696                    && r.message.contains("discount")
697            }),
698            "ratio must flag: {recs:?}"
699        );
700    }
701
702    #[test]
703    fn text_not_flagged_for_bounds() {
704        let engine = load(
705            r#"spec pricing 2026-01-01
706"""
707x
708"""
709
710data status: text
711  -> help "Status."
712rule ok: status is "active"
713"#,
714        );
715        assert!(
716            !engine
717                .quality()
718                .iter()
719                .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
720            "text must not get bounds rec: {:?}",
721            engine.quality()
722        );
723    }
724
725    #[test]
726    fn data_missing_help() {
727        let engine = load(
728            r#"spec pricing 2026-01-01
729"""
730x
731"""
732
733data qty: number
734rule total: qty
735"#,
736        );
737        let hit = engine
738            .quality()
739            .into_iter()
740            .find(|r| r.message.contains("no `-> help`") && r.message.contains("qty"))
741            .expect("missing help");
742        assert!(
743            hit.message.contains("Consider adding a message"),
744            "got: {}",
745            hit.message
746        );
747        assert_eq!(hit.spec, "pricing");
748        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
749        assert!(
750            hit.to_string().starts_with(
751                "In spec 'pricing' (effective from 2026-01-01): `qty` has no `-> help`"
752            ),
753            "Display must include effective_from, got: {}",
754            hit
755        );
756        assert!(
757            hit.to_string().contains(" at test.lemma:"),
758            "Display must append source location, got: {}",
759            hit
760        );
761    }
762
763    #[test]
764    fn text_without_options() {
765        let engine = load(
766            r#"spec pricing 2026-01-01
767"""
768x
769"""
770
771data status: text
772  -> help "Status."
773rule ok: status is "active"
774"#,
775        );
776        let hit = engine
777            .quality()
778            .into_iter()
779            .find(|r| r.message.contains("accepts any text") && r.message.contains("status"))
780            .expect("text without options");
781        assert!(
782            hit.message.contains("offer choices"),
783            "got: {}",
784            hit.message
785        );
786        assert_eq!(hit.spec, "pricing");
787        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
788    }
789
790    #[test]
791    fn veto_as_rejection_cascade() {
792        let engine = load(
793            r#"spec eligibility 2026-01-01
794"""
795Age gate.
796"""
797
798data age: number
799  -> help "Customer age."
800
801rule is_eligible: true
802  unless age < 18 then veto "Must be 18+"
803"#,
804        );
805        let hit = engine
806            .quality()
807            .into_iter()
808            .find(|r| {
809                r.message.contains("treats a yes/no outcome as veto")
810                    && r.message.contains("is_eligible")
811            })
812            .expect("veto cascade");
813        assert_eq!(hit.spec, "eligibility");
814        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
815    }
816
817    #[test]
818    fn boolean_denial_is_not_cascade() {
819        let engine = load(
820            r#"spec eligibility 2026-01-01
821"""
822Age gate.
823"""
824
825data age: number
826  -> help "Customer age."
827
828rule is_eligible: true
829  unless age < 18 then false
830"#,
831        );
832        assert!(!engine
833            .quality()
834            .iter()
835            .any(|r| r.message.contains("treats a yes/no outcome as veto")));
836    }
837
838    #[test]
839    fn stdlib_dependency_not_reported() {
840        let engine = load(
841            r#"spec ship 2026-01-01
842"""
843Weight check.
844"""
845
846uses lemma units
847
848data package_weight: units.mass
849  -> help "Package weight."
850
851rule heavy: package_weight > 20 kilogram
852"#,
853        );
854        let recs = engine.quality();
855        assert!(
856            recs.iter().all(|r| r.spec != "units"),
857            "stdlib units must not appear: {recs:?}"
858        );
859    }
860
861    #[test]
862    fn temporal_slices_distinct_by_effective_from() {
863        let engine = load(
864            r#"spec pricing 1933-01-01
865"""
866Old.
867"""
868
869data qty: number
870rule total: qty
871
872spec pricing 2026-01-01
873"""
874New.
875"""
876
877data qty: number
878rule total: qty
879"#,
880        );
881        let helps: Vec<_> = engine
882            .quality()
883            .into_iter()
884            .filter(|r| r.message.contains("no `-> help`"))
885            .collect();
886        assert_eq!(helps.len(), 2, "got: {helps:?}");
887        assert!(
888            helps.iter().any(|r| {
889                r.spec == "pricing" && effective_display(r).as_deref() == Some("1933-01-01")
890            }),
891            "missing 1933 slice: {helps:?}"
892        );
893        assert!(
894            helps.iter().any(|r| {
895                r.spec == "pricing" && effective_display(r).as_deref() == Some("2026-01-01")
896            }),
897            "missing 2026 slice: {helps:?}"
898        );
899        assert!(
900            helps
901                .iter()
902                .all(|r| !r.message.contains("1933") && !r.message.contains("2026")),
903            "message must not carry temporal identity: {helps:?}"
904        );
905        let displays: Vec<String> = helps.iter().map(|r| r.to_string()).collect();
906        assert!(
907            displays
908                .iter()
909                .any(|s| s.contains("In spec 'pricing' (effective from 1933-01-01)")),
910            "Display missing 1933: {displays:?}"
911        );
912        assert!(
913            displays
914                .iter()
915                .any(|s| s.contains("In spec 'pricing' (effective from 2026-01-01)")),
916            "Display missing 2026: {displays:?}"
917        );
918    }
919
920    #[test]
921    fn recommendation_wire_json_uses_source_not_source_location() {
922        let engine = load(
923            r#"spec pricing 2026-01-01
924"""
925x
926"""
927
928data qty: number
929rule total: qty
930"#,
931        );
932        let hit = engine
933            .quality()
934            .into_iter()
935            .find(|r| r.message.contains("no `-> help`"))
936            .expect("missing help");
937        let json = serde_json::to_value(&hit).expect("serialize");
938        assert!(json.get("source").is_some(), "wire must use source: {json}");
939        assert!(
940            json.get("source_location").is_none(),
941            "wire must not expose source_location: {json}"
942        );
943        assert_eq!(json["source"]["attribute"], serde_json::json!("test.lemma"));
944        let round: Recommendation = serde_json::from_value(json).expect("deserialize");
945        assert_eq!(round.spec, "pricing");
946        assert!(round.message.contains("no `-> help`"));
947    }
948
949    fn and_chain_spec(rule_body: &str) -> String {
950        format!(
951            r#"spec gate 2026-01-01
952"""
953Gate.
954"""
955
956data ready: boolean
957  -> help "Ready?"
958data eligible: boolean
959  -> help "Eligible?"
960
961rule pass: no
962  {rule_body}
963"#
964        )
965    }
966
967    fn ambiguous_and_hits(engine: &Engine) -> Vec<Recommendation> {
968        engine
969            .quality()
970            .into_iter()
971            .filter(|r| r.message.contains("Boolean `and` mixes a bare name"))
972            .collect()
973    }
974
975    #[test]
976    fn flags_not_ref_and_bare() {
977        let engine = load(&and_chain_spec("unless not ready and eligible then yes"));
978        let hits = ambiguous_and_hits(&engine);
979        assert_eq!(hits.len(), 1, "got: {hits:?}");
980        assert_eq!(hits[0].spec, "gate");
981        assert!(
982            hits[0].message.contains("Prefer parallel probes"),
983            "got: {}",
984            hits[0].message
985        );
986    }
987
988    #[test]
989    fn flags_bare_and_is_false() {
990        let engine = load(&and_chain_spec(
991            "unless ready and eligible is false then yes",
992        ));
993        let hits = ambiguous_and_hits(&engine);
994        assert_eq!(hits.len(), 1, "got: {hits:?}");
995        assert_eq!(hits[0].spec, "gate");
996    }
997
998    #[test]
999    fn clean_not_and_is_true() {
1000        let engine = load(&and_chain_spec(
1001            "unless not ready and eligible is true then yes",
1002        ));
1003        assert!(
1004            ambiguous_and_hits(&engine).is_empty(),
1005            "got: {:?}",
1006            ambiguous_and_hits(&engine)
1007        );
1008    }
1009
1010    #[test]
1011    fn clean_is_false_and_is_true() {
1012        let engine = load(&and_chain_spec(
1013            "unless ready is false and eligible is true then yes",
1014        ));
1015        assert!(
1016            ambiguous_and_hits(&engine).is_empty(),
1017            "got: {:?}",
1018            ambiguous_and_hits(&engine)
1019        );
1020    }
1021
1022    #[test]
1023    fn clean_unary_not() {
1024        let engine = load(&and_chain_spec("unless not ready then yes"));
1025        assert!(
1026            ambiguous_and_hits(&engine).is_empty(),
1027            "got: {:?}",
1028            ambiguous_and_hits(&engine)
1029        );
1030    }
1031
1032    #[test]
1033    fn clean_bare_and_bare() {
1034        let engine = load(&and_chain_spec("unless ready and eligible then yes"));
1035        assert!(
1036            ambiguous_and_hits(&engine).is_empty(),
1037            "got: {:?}",
1038            ambiguous_and_hits(&engine)
1039        );
1040    }
1041
1042    fn redundant_unless_hits(engine: &Engine) -> Vec<Recommendation> {
1043        engine
1044            .quality()
1045            .into_iter()
1046            .filter(|r| r.message.contains("single `unless … then yes`"))
1047            .collect()
1048    }
1049
1050    #[test]
1051    fn flags_redundant_no_unless_yes() {
1052        let engine = load(&and_chain_spec("unless ready and eligible then yes"));
1053        let hits = redundant_unless_hits(&engine);
1054        assert_eq!(hits.len(), 1, "got: {hits:?}");
1055        assert!(
1056            hits[0].message.contains("ready and eligible"),
1057            "{}",
1058            hits[0].message
1059        );
1060    }
1061
1062    #[test]
1063    fn flags_redundant_false_unless_true() {
1064        let engine = load(
1065            r#"spec gate
1066data ready: boolean
1067
1068rule pass: false
1069  unless ready then true
1070"#,
1071        );
1072        let hits = redundant_unless_hits(&engine);
1073        assert_eq!(hits.len(), 1, "got: {hits:?}");
1074    }
1075
1076    #[test]
1077    fn flags_redundant_no_unless_not_ready_then_yes() {
1078        let engine = load(&and_chain_spec("unless not ready then yes"));
1079        let hits = redundant_unless_hits(&engine);
1080        assert_eq!(hits.len(), 1, "got: {hits:?}");
1081        assert!(hits[0].message.contains("not ready"), "{}", hits[0].message);
1082    }
1083
1084    #[test]
1085    fn clean_symmetric_yes_unless_no() {
1086        let engine = load(
1087            r#"spec gate
1088data ready: boolean
1089data eligible: boolean
1090
1091rule pass: yes
1092  unless ready and eligible then no
1093"#,
1094        );
1095        assert!(
1096            redundant_unless_hits(&engine).is_empty(),
1097            "got: {:?}",
1098            redundant_unless_hits(&engine)
1099        );
1100    }
1101
1102    #[test]
1103    fn clean_multi_unless_discount() {
1104        let engine = load(
1105            r#"spec vip_discount
1106data qty: number
1107  -> minimum 0
1108data is_vip: boolean
1109
1110rule discount: 0%
1111  unless qty >= 10 then 10%
1112  unless qty >= 50 then 20%
1113  unless is_vip    then 25%
1114"#,
1115        );
1116        assert!(
1117            redundant_unless_hits(&engine).is_empty(),
1118            "got: {:?}",
1119            redundant_unless_hits(&engine)
1120        );
1121    }
1122
1123    #[test]
1124    fn clean_multi_unless_needs_jacket() {
1125        let engine = load(
1126            r#"spec weather
1127uses lemma units
1128
1129data temperature: measure
1130  -> unit celsius: 1.0
1131data is_raining: boolean
1132data wind_speed: number
1133
1134rule needs_jacket: no
1135  unless temperature < 15 celsius then yes
1136  unless is_raining               then yes
1137  unless wind_speed > 20          then yes
1138"#,
1139        );
1140        assert!(
1141            redundant_unless_hits(&engine).is_empty(),
1142            "got: {:?}",
1143            redundant_unless_hits(&engine)
1144        );
1145    }
1146
1147    #[test]
1148    fn clean_direct_boolean_rule() {
1149        let engine = load(
1150            r#"spec gate
1151data ready: boolean
1152data eligible: boolean
1153
1154rule pass: ready and eligible
1155"#,
1156        );
1157        assert!(
1158            redundant_unless_hits(&engine).is_empty(),
1159            "got: {:?}",
1160            redundant_unless_hits(&engine)
1161        );
1162    }
1163
1164    #[test]
1165    fn clean_non_boolean_unless() {
1166        let engine = load(
1167            r#"spec pricing
1168data qty: number
1169
1170rule discount: 0%
1171  unless qty >= 10 then 10%
1172"#,
1173        );
1174        assert!(
1175            redundant_unless_hits(&engine).is_empty(),
1176            "got: {:?}",
1177            redundant_unless_hits(&engine)
1178        );
1179    }
1180
1181    #[test]
1182    fn deprecated_unit_without_colon_emits_quality_recommendation() {
1183        let engine = load(
1184            r#"spec money_spec
1185data money: measure
1186  -> unit eur 1.00
1187  -> help "Money amount."
1188"#,
1189        );
1190        let hit = engine
1191            .quality()
1192            .into_iter()
1193            .find(|r| r.message.contains("deprecated") && r.spec == "money_spec")
1194            .expect("deprecated unit without colon must produce quality recommendation");
1195        assert!(
1196            hit.message.contains("unit") && hit.message.contains(":"),
1197            "got: {}",
1198            hit.message
1199        );
1200    }
1201
1202    #[test]
1203    fn unit_with_colon_has_no_deprecated_unit_recommendation() {
1204        let engine = load(
1205            r#"spec money_spec
1206data money: measure
1207  -> unit eur: 1.00
1208  -> help "Money amount."
1209"#,
1210        );
1211        assert!(
1212            engine
1213                .quality()
1214                .iter()
1215                .all(|r| !r.message.contains("deprecated")),
1216            "colon unit syntax must not flag deprecated: {:?}",
1217            engine.quality()
1218        );
1219    }
1220}