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::ResultIsVeto(inner) => {
440            walk_expr_for_ambiguous_and(repository, spec, rule, inner, out);
441        }
442        ExpressionKind::DateCalendar(_, _, inner) => {
443            walk_expr_for_ambiguous_and(repository, spec, rule, inner, out);
444        }
445        ExpressionKind::RangeLiteral(left, right)
446        | ExpressionKind::RangeContainment(left, right)
447        | ExpressionKind::Arithmetic(left, _, right)
448        | ExpressionKind::Comparison(left, _, right) => {
449            walk_expr_for_ambiguous_and(repository.clone(), spec, rule, left, out);
450            walk_expr_for_ambiguous_and(repository, spec, rule, right, out);
451        }
452        ExpressionKind::Literal(_)
453        | ExpressionKind::Reference(_)
454        | ExpressionKind::Now
455        | ExpressionKind::Veto(_) => {}
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::parsing::source::SourceType;
463
464    fn load(code: &str) -> Engine {
465        let mut engine = Engine::new();
466        engine
467            .load([(
468                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
469                code.to_string(),
470            )])
471            .expect("BUG: test source must load");
472        engine
473    }
474
475    fn effective_display(r: &Recommendation) -> Option<String> {
476        r.effective_from.as_ref().map(|d| d.to_string())
477    }
478
479    #[test]
480    fn origin_without_commentary_does_not_flag_optional_gaps() {
481        let engine = load("spec pricing\ndata x: number\nrule y: x\n");
482        let recs = engine.quality();
483        assert!(
484            recs.iter().all(|r| {
485                !r.message.contains("commentary")
486                    && !r.message.contains("effective date")
487                    && !r.message.contains("-> suggest")
488            }),
489            "optional gaps must not be recommended: {recs:?}"
490        );
491        let help = recs
492            .iter()
493            .find(|r| r.message.contains("no `-> help`") && r.message.contains("x"))
494            .expect("missing help");
495        assert!(
496            help.message.contains("Consider adding a message"),
497            "got: {}",
498            help.message
499        );
500        assert_eq!(help.spec, "pricing");
501        assert_eq!(help.effective_from, None);
502    }
503
504    #[test]
505    fn clean_spec_has_no_recommendations() {
506        let engine = load(
507            r#"spec pricing 2026-01-01
508"""
509Bulk pricing.
510"""
511
512data qty: number
513  -> minimum 0
514  -> maximum 1000000
515  -> help "Order quantity."
516
517rule total: qty
518"#,
519        );
520        assert!(engine.quality().is_empty(), "got: {:?}", engine.quality());
521    }
522
523    #[test]
524    fn deprecated_standalone_with_emits_quality_recommendation() {
525        let engine = load(
526            r#"spec inner
527data x: number
528
529spec outer
530uses i: inner
531with i.x: 42
532rule r: i.x
533"#,
534        );
535        let recs = engine.quality();
536        let hit = recs
537            .iter()
538            .find(|r| r.message.contains("deprecated") && r.spec == "outer")
539            .expect("deprecated standalone with must produce quality recommendation");
540        assert!(hit.message.contains("-> with"), "got: {}", hit.message);
541        assert_eq!(hit.source_location.span.line, 6);
542    }
543
544    #[test]
545    fn block_uses_binding_has_no_deprecated_recommendation() {
546        let engine = load(
547            r#"spec inner
548data x: number
549
550spec outer
551uses i: inner
552  -> with x: 42
553rule r: i.x
554"#,
555        );
556        assert!(
557            engine
558                .quality()
559                .iter()
560                .all(|r| !r.message.contains("deprecated")),
561            "block syntax must not flag deprecated: {:?}",
562            engine.quality()
563        );
564    }
565
566    #[test]
567    fn number_without_bounds() {
568        let engine = load(
569            r#"spec pricing 2026-01-01
570"""
571x
572"""
573
574data qty: number
575  -> help "Order quantity."
576rule total: qty
577"#,
578        );
579        let hit = engine
580            .quality()
581            .into_iter()
582            .find(|r| {
583                r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("qty")
584            })
585            .expect("missing bounds");
586        assert!(
587            hit.message.contains("Consider adding bounds"),
588            "got: {}",
589            hit.message
590        );
591    }
592
593    #[test]
594    fn number_with_only_minimum_still_flagged() {
595        let engine = load(
596            r#"spec pricing 2026-01-01
597"""
598x
599"""
600
601data qty: number
602  -> minimum 0
603  -> help "Order quantity."
604rule total: qty
605"#,
606        );
607        assert!(
608            engine.quality().iter().any(|r| {
609                r.message.contains("no `-> maximum`")
610                    && !r.message.contains("no `-> minimum` or")
611                    && r.message.contains("qty")
612            }),
613            "only minimum must flag missing maximum: {:?}",
614            engine.quality()
615        );
616    }
617
618    #[test]
619    fn number_with_only_maximum_still_flagged() {
620        let engine = load(
621            r#"spec pricing 2026-01-01
622"""
623x
624"""
625
626data qty: number
627  -> maximum 100
628  -> help "Order quantity."
629rule total: qty
630"#,
631        );
632        assert!(
633            engine.quality().iter().any(|r| {
634                r.message.contains("no `-> minimum`")
635                    && !r.message.contains("or `-> maximum`")
636                    && r.message.contains("qty")
637            }),
638            "only maximum must flag missing minimum: {:?}",
639            engine.quality()
640        );
641    }
642
643    #[test]
644    fn number_with_min_and_max_clean() {
645        let engine = load(
646            r#"spec pricing 2026-01-01
647"""
648x
649"""
650
651data qty: number
652  -> minimum 0
653  -> maximum 100
654  -> help "Order quantity."
655rule total: qty
656"#,
657        );
658        assert!(
659            !engine
660                .quality()
661                .iter()
662                .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
663            "min+max must not flag bounds: {:?}",
664            engine.quality()
665        );
666    }
667
668    #[test]
669    fn measure_and_ratio_without_bounds() {
670        let engine = load(
671            r#"spec pricing 2026-01-01
672"""
673x
674"""
675
676data price: measure
677  -> unit eur: 1
678  -> help "Unit price."
679data discount: ratio
680  -> help "Discount rate."
681rule total: price
682rule rate: discount
683"#,
684        );
685        let recs = engine.quality();
686        assert!(
687            recs.iter().any(|r| {
688                r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("price")
689            }),
690            "measure must flag: {recs:?}"
691        );
692        assert!(
693            recs.iter().any(|r| {
694                r.message.contains("no `-> minimum` or `-> maximum`")
695                    && r.message.contains("discount")
696            }),
697            "ratio must flag: {recs:?}"
698        );
699    }
700
701    #[test]
702    fn text_not_flagged_for_bounds() {
703        let engine = load(
704            r#"spec pricing 2026-01-01
705"""
706x
707"""
708
709data status: text
710  -> help "Status."
711rule ok: status is "active"
712"#,
713        );
714        assert!(
715            !engine
716                .quality()
717                .iter()
718                .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
719            "text must not get bounds rec: {:?}",
720            engine.quality()
721        );
722    }
723
724    #[test]
725    fn data_missing_help() {
726        let engine = load(
727            r#"spec pricing 2026-01-01
728"""
729x
730"""
731
732data qty: number
733rule total: qty
734"#,
735        );
736        let hit = engine
737            .quality()
738            .into_iter()
739            .find(|r| r.message.contains("no `-> help`") && r.message.contains("qty"))
740            .expect("missing help");
741        assert!(
742            hit.message.contains("Consider adding a message"),
743            "got: {}",
744            hit.message
745        );
746        assert_eq!(hit.spec, "pricing");
747        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
748        assert!(
749            hit.to_string().starts_with(
750                "In spec 'pricing' (effective from 2026-01-01): `qty` has no `-> help`"
751            ),
752            "Display must include effective_from, got: {}",
753            hit
754        );
755        assert!(
756            hit.to_string().contains(" at test.lemma:"),
757            "Display must append source location, got: {}",
758            hit
759        );
760    }
761
762    #[test]
763    fn text_without_options() {
764        let engine = load(
765            r#"spec pricing 2026-01-01
766"""
767x
768"""
769
770data status: text
771  -> help "Status."
772rule ok: status is "active"
773"#,
774        );
775        let hit = engine
776            .quality()
777            .into_iter()
778            .find(|r| r.message.contains("accepts any text") && r.message.contains("status"))
779            .expect("text without options");
780        assert!(
781            hit.message.contains("offer choices"),
782            "got: {}",
783            hit.message
784        );
785        assert_eq!(hit.spec, "pricing");
786        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
787    }
788
789    #[test]
790    fn veto_as_rejection_cascade() {
791        let engine = load(
792            r#"spec eligibility 2026-01-01
793"""
794Age gate.
795"""
796
797data age: number
798  -> help "Customer age."
799
800rule is_eligible: true
801  unless age < 18 then veto "Must be 18+"
802"#,
803        );
804        let hit = engine
805            .quality()
806            .into_iter()
807            .find(|r| {
808                r.message.contains("treats a yes/no outcome as veto")
809                    && r.message.contains("is_eligible")
810            })
811            .expect("veto cascade");
812        assert_eq!(hit.spec, "eligibility");
813        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
814    }
815
816    #[test]
817    fn boolean_denial_is_not_cascade() {
818        let engine = load(
819            r#"spec eligibility 2026-01-01
820"""
821Age gate.
822"""
823
824data age: number
825  -> help "Customer age."
826
827rule is_eligible: true
828  unless age < 18 then false
829"#,
830        );
831        assert!(!engine
832            .quality()
833            .iter()
834            .any(|r| r.message.contains("treats a yes/no outcome as veto")));
835    }
836
837    #[test]
838    fn stdlib_dependency_not_reported() {
839        let engine = load(
840            r#"spec ship 2026-01-01
841"""
842Weight check.
843"""
844
845uses lemma units
846
847data package_weight: units.mass
848  -> help "Package weight."
849
850rule heavy: package_weight > 20 kilogram
851"#,
852        );
853        let recs = engine.quality();
854        assert!(
855            recs.iter().all(|r| r.spec != "units"),
856            "stdlib units must not appear: {recs:?}"
857        );
858    }
859
860    #[test]
861    fn temporal_slices_distinct_by_effective_from() {
862        let engine = load(
863            r#"spec pricing 1933-01-01
864"""
865Old.
866"""
867
868data qty: number
869rule total: qty
870
871spec pricing 2026-01-01
872"""
873New.
874"""
875
876data qty: number
877rule total: qty
878"#,
879        );
880        let helps: Vec<_> = engine
881            .quality()
882            .into_iter()
883            .filter(|r| r.message.contains("no `-> help`"))
884            .collect();
885        assert_eq!(helps.len(), 2, "got: {helps:?}");
886        assert!(
887            helps.iter().any(|r| {
888                r.spec == "pricing" && effective_display(r).as_deref() == Some("1933-01-01")
889            }),
890            "missing 1933 slice: {helps:?}"
891        );
892        assert!(
893            helps.iter().any(|r| {
894                r.spec == "pricing" && effective_display(r).as_deref() == Some("2026-01-01")
895            }),
896            "missing 2026 slice: {helps:?}"
897        );
898        assert!(
899            helps
900                .iter()
901                .all(|r| !r.message.contains("1933") && !r.message.contains("2026")),
902            "message must not carry temporal identity: {helps:?}"
903        );
904        let displays: Vec<String> = helps.iter().map(|r| r.to_string()).collect();
905        assert!(
906            displays
907                .iter()
908                .any(|s| s.contains("In spec 'pricing' (effective from 1933-01-01)")),
909            "Display missing 1933: {displays:?}"
910        );
911        assert!(
912            displays
913                .iter()
914                .any(|s| s.contains("In spec 'pricing' (effective from 2026-01-01)")),
915            "Display missing 2026: {displays:?}"
916        );
917    }
918
919    #[test]
920    fn recommendation_wire_json_uses_source_not_source_location() {
921        let engine = load(
922            r#"spec pricing 2026-01-01
923"""
924x
925"""
926
927data qty: number
928rule total: qty
929"#,
930        );
931        let hit = engine
932            .quality()
933            .into_iter()
934            .find(|r| r.message.contains("no `-> help`"))
935            .expect("missing help");
936        let json = serde_json::to_value(&hit).expect("serialize");
937        assert!(json.get("source").is_some(), "wire must use source: {json}");
938        assert!(
939            json.get("source_location").is_none(),
940            "wire must not expose source_location: {json}"
941        );
942        assert_eq!(json["source"]["attribute"], serde_json::json!("test.lemma"));
943        let round: Recommendation = serde_json::from_value(json).expect("deserialize");
944        assert_eq!(round.spec, "pricing");
945        assert!(round.message.contains("no `-> help`"));
946    }
947
948    fn and_chain_spec(rule_body: &str) -> String {
949        format!(
950            r#"spec gate 2026-01-01
951"""
952Gate.
953"""
954
955data ready: boolean
956  -> help "Ready?"
957data eligible: boolean
958  -> help "Eligible?"
959
960rule pass: no
961  {rule_body}
962"#
963        )
964    }
965
966    fn ambiguous_and_hits(engine: &Engine) -> Vec<Recommendation> {
967        engine
968            .quality()
969            .into_iter()
970            .filter(|r| r.message.contains("Boolean `and` mixes a bare name"))
971            .collect()
972    }
973
974    #[test]
975    fn flags_not_ref_and_bare() {
976        let engine = load(&and_chain_spec("unless not ready and eligible then yes"));
977        let hits = ambiguous_and_hits(&engine);
978        assert_eq!(hits.len(), 1, "got: {hits:?}");
979        assert_eq!(hits[0].spec, "gate");
980        assert!(
981            hits[0].message.contains("Prefer parallel probes"),
982            "got: {}",
983            hits[0].message
984        );
985    }
986
987    #[test]
988    fn flags_bare_and_is_false() {
989        let engine = load(&and_chain_spec(
990            "unless ready and eligible is false then yes",
991        ));
992        let hits = ambiguous_and_hits(&engine);
993        assert_eq!(hits.len(), 1, "got: {hits:?}");
994        assert_eq!(hits[0].spec, "gate");
995    }
996
997    #[test]
998    fn clean_not_and_is_true() {
999        let engine = load(&and_chain_spec(
1000            "unless not ready and eligible is true then yes",
1001        ));
1002        assert!(
1003            ambiguous_and_hits(&engine).is_empty(),
1004            "got: {:?}",
1005            ambiguous_and_hits(&engine)
1006        );
1007    }
1008
1009    #[test]
1010    fn clean_is_false_and_is_true() {
1011        let engine = load(&and_chain_spec(
1012            "unless ready is false and eligible is true then yes",
1013        ));
1014        assert!(
1015            ambiguous_and_hits(&engine).is_empty(),
1016            "got: {:?}",
1017            ambiguous_and_hits(&engine)
1018        );
1019    }
1020
1021    #[test]
1022    fn clean_unary_not() {
1023        let engine = load(&and_chain_spec("unless not ready then yes"));
1024        assert!(
1025            ambiguous_and_hits(&engine).is_empty(),
1026            "got: {:?}",
1027            ambiguous_and_hits(&engine)
1028        );
1029    }
1030
1031    #[test]
1032    fn clean_bare_and_bare() {
1033        let engine = load(&and_chain_spec("unless ready and eligible then yes"));
1034        assert!(
1035            ambiguous_and_hits(&engine).is_empty(),
1036            "got: {:?}",
1037            ambiguous_and_hits(&engine)
1038        );
1039    }
1040
1041    fn redundant_unless_hits(engine: &Engine) -> Vec<Recommendation> {
1042        engine
1043            .quality()
1044            .into_iter()
1045            .filter(|r| r.message.contains("single `unless … then yes`"))
1046            .collect()
1047    }
1048
1049    #[test]
1050    fn flags_redundant_no_unless_yes() {
1051        let engine = load(&and_chain_spec("unless ready and eligible then yes"));
1052        let hits = redundant_unless_hits(&engine);
1053        assert_eq!(hits.len(), 1, "got: {hits:?}");
1054        assert!(
1055            hits[0].message.contains("ready and eligible"),
1056            "{}",
1057            hits[0].message
1058        );
1059    }
1060
1061    #[test]
1062    fn flags_redundant_false_unless_true() {
1063        let engine = load(
1064            r#"spec gate
1065data ready: boolean
1066
1067rule pass: false
1068  unless ready then true
1069"#,
1070        );
1071        let hits = redundant_unless_hits(&engine);
1072        assert_eq!(hits.len(), 1, "got: {hits:?}");
1073    }
1074
1075    #[test]
1076    fn flags_redundant_no_unless_not_ready_then_yes() {
1077        let engine = load(&and_chain_spec("unless not ready then yes"));
1078        let hits = redundant_unless_hits(&engine);
1079        assert_eq!(hits.len(), 1, "got: {hits:?}");
1080        assert!(hits[0].message.contains("not ready"), "{}", hits[0].message);
1081    }
1082
1083    #[test]
1084    fn clean_symmetric_yes_unless_no() {
1085        let engine = load(
1086            r#"spec gate
1087data ready: boolean
1088data eligible: boolean
1089
1090rule pass: yes
1091  unless ready and eligible then no
1092"#,
1093        );
1094        assert!(
1095            redundant_unless_hits(&engine).is_empty(),
1096            "got: {:?}",
1097            redundant_unless_hits(&engine)
1098        );
1099    }
1100
1101    #[test]
1102    fn clean_multi_unless_discount() {
1103        let engine = load(
1104            r#"spec vip_discount
1105data qty: number
1106  -> minimum 0
1107data is_vip: boolean
1108
1109rule discount: 0%
1110  unless qty >= 10 then 10%
1111  unless qty >= 50 then 20%
1112  unless is_vip    then 25%
1113"#,
1114        );
1115        assert!(
1116            redundant_unless_hits(&engine).is_empty(),
1117            "got: {:?}",
1118            redundant_unless_hits(&engine)
1119        );
1120    }
1121
1122    #[test]
1123    fn clean_multi_unless_needs_jacket() {
1124        let engine = load(
1125            r#"spec weather
1126uses lemma units
1127
1128data temperature: measure
1129  -> unit celsius: 1.0
1130data is_raining: boolean
1131data wind_speed: number
1132
1133rule needs_jacket: no
1134  unless temperature < 15 celsius then yes
1135  unless is_raining               then yes
1136  unless wind_speed > 20          then yes
1137"#,
1138        );
1139        assert!(
1140            redundant_unless_hits(&engine).is_empty(),
1141            "got: {:?}",
1142            redundant_unless_hits(&engine)
1143        );
1144    }
1145
1146    #[test]
1147    fn clean_direct_boolean_rule() {
1148        let engine = load(
1149            r#"spec gate
1150data ready: boolean
1151data eligible: boolean
1152
1153rule pass: ready and eligible
1154"#,
1155        );
1156        assert!(
1157            redundant_unless_hits(&engine).is_empty(),
1158            "got: {:?}",
1159            redundant_unless_hits(&engine)
1160        );
1161    }
1162
1163    #[test]
1164    fn clean_non_boolean_unless() {
1165        let engine = load(
1166            r#"spec pricing
1167data qty: number
1168
1169rule discount: 0%
1170  unless qty >= 10 then 10%
1171"#,
1172        );
1173        assert!(
1174            redundant_unless_hits(&engine).is_empty(),
1175            "got: {:?}",
1176            redundant_unless_hits(&engine)
1177        );
1178    }
1179
1180    #[test]
1181    fn deprecated_unit_without_colon_emits_quality_recommendation() {
1182        let engine = load(
1183            r#"spec money_spec
1184data money: measure
1185  -> unit eur 1.00
1186  -> help "Money amount."
1187"#,
1188        );
1189        let hit = engine
1190            .quality()
1191            .into_iter()
1192            .find(|r| r.message.contains("deprecated") && r.spec == "money_spec")
1193            .expect("deprecated unit without colon must produce quality recommendation");
1194        assert!(
1195            hit.message.contains("unit") && hit.message.contains(":"),
1196            "got: {}",
1197            hit.message
1198        );
1199    }
1200
1201    #[test]
1202    fn unit_with_colon_has_no_deprecated_unit_recommendation() {
1203        let engine = load(
1204            r#"spec money_spec
1205data money: measure
1206  -> unit eur: 1.00
1207  -> help "Money amount."
1208"#,
1209        );
1210        assert!(
1211            engine
1212                .quality()
1213                .iter()
1214                .all(|r| !r.message.contains("deprecated")),
1215            "colon unit syntax must not flag deprecated: {:?}",
1216            engine.quality()
1217        );
1218    }
1219}