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