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