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::Value;
8use crate::parsing::ast::{
9    DataValue, Expression, ExpressionKind, LemmaData, LemmaRule, LemmaSpec, ParentType,
10    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_data(
154    repository: Option<String>,
155    spec: &LemmaSpec,
156    data: &LemmaData,
157    out: &mut Vec<Recommendation>,
158) {
159    let DataValue::Definition {
160        base,
161        constraints,
162        value: _,
163    } = &data.value
164    else {
165        return;
166    };
167
168    let name = data.reference.name.clone();
169    let constraints = constraints.as_deref().unwrap_or(&[]);
170    let has_help = constraints
171        .iter()
172        .any(|(c, _)| matches!(c, TypeConstraintCommand::Help));
173    let has_option = constraints.iter().any(|(c, _)| {
174        matches!(
175            c,
176            TypeConstraintCommand::Option | TypeConstraintCommand::Options
177        )
178    });
179    let has_minimum = constraints
180        .iter()
181        .any(|(c, _)| matches!(c, TypeConstraintCommand::Minimum));
182    let has_maximum = constraints
183        .iter()
184        .any(|(c, _)| matches!(c, TypeConstraintCommand::Maximum));
185
186    if !has_help {
187        out.push(Recommendation {
188            message: format!(
189                "`{name}` has no `-> help`. Consider adding a message to help users understand this data."
190            ),
191            repository: repository.clone(),
192            spec: spec.name.clone(),
193            effective_from: spec.effective_from.to_option(),
194            source_location: data.source_location.clone(),
195        });
196    }
197
198    if is_primitive_bounded_quantity(base.as_ref()) && (!has_minimum || !has_maximum) {
199        let gap = match (has_minimum, has_maximum) {
200            (false, false) => "no `-> minimum` or `-> maximum`",
201            (true, false) => "no `-> maximum`",
202            (false, true) => "no `-> minimum`",
203            (true, true) => unreachable!("BUG: both bounds present but entered missing-bounds arm"),
204        };
205        out.push(Recommendation {
206            message: format!(
207                "`{name}` has {gap}. Consider adding bounds so out-of-range values are rejected."
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_text(base.as_ref()) && !has_option {
217        out.push(Recommendation {
218            message: format!(
219                "`{name}` accepts any text. Adding `-> option` values allows forms and APIs to offer choices."
220            ),
221            repository,
222            spec: spec.name.clone(),
223            effective_from: spec.effective_from.to_option(),
224            source_location: data.source_location.clone(),
225        });
226    }
227}
228
229fn unwrap_parent_type(base: Option<&ParentType>) -> Option<&ParentType> {
230    match base {
231        Some(ParentType::Qualified { inner, .. } | ParentType::Ranged { inner }) => {
232            unwrap_parent_type(Some(inner.as_ref()))
233        }
234        other => other,
235    }
236}
237
238fn is_primitive_text(base: Option<&ParentType>) -> bool {
239    matches!(
240        unwrap_parent_type(base),
241        Some(ParentType::Primitive {
242            primitive: PrimitiveKind::Text
243        })
244    )
245}
246
247fn is_primitive_bounded_quantity(base: Option<&ParentType>) -> bool {
248    matches!(
249        unwrap_parent_type(base),
250        Some(ParentType::Primitive {
251            primitive: PrimitiveKind::Number | PrimitiveKind::Measure | PrimitiveKind::Ratio
252        })
253    )
254}
255
256fn analyze_rule(
257    repository: Option<String>,
258    spec: &LemmaSpec,
259    rule: &LemmaRule,
260    out: &mut Vec<Recommendation>,
261) {
262    if !is_boolean_literal(&rule.expression) {
263        return;
264    }
265    if rule.unless_clauses.is_empty() {
266        return;
267    }
268    let all_veto = rule
269        .unless_clauses
270        .iter()
271        .all(|u| matches!(u.result.kind, ExpressionKind::Veto(_)));
272    if !all_veto {
273        return;
274    }
275    out.push(Recommendation {
276        message: format!(
277            "`{}` 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.",
278            rule.name
279        ),
280        repository,
281        spec: spec.name.clone(),
282        effective_from: spec.effective_from.to_option(),
283        source_location: rule.source_location.clone(),
284    });
285}
286
287fn is_boolean_literal(expr: &Expression) -> bool {
288    matches!(&expr.kind, ExpressionKind::Literal(Value::Boolean(_)))
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::parsing::source::SourceType;
295
296    fn load(code: &str) -> Engine {
297        let mut engine = Engine::new();
298        engine
299            .load([(
300                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
301                code.to_string(),
302            )])
303            .expect("BUG: test source must load");
304        engine
305    }
306
307    fn effective_display(r: &Recommendation) -> Option<String> {
308        r.effective_from.as_ref().map(|d| d.to_string())
309    }
310
311    #[test]
312    fn origin_without_commentary_does_not_flag_optional_gaps() {
313        let engine = load("spec pricing\ndata x: number\nrule y: x\n");
314        let recs = engine.quality();
315        assert!(
316            recs.iter().all(|r| {
317                !r.message.contains("commentary")
318                    && !r.message.contains("effective date")
319                    && !r.message.contains("-> suggest")
320            }),
321            "optional gaps must not be recommended: {recs:?}"
322        );
323        let help = recs
324            .iter()
325            .find(|r| r.message.contains("no `-> help`") && r.message.contains("x"))
326            .expect("missing help");
327        assert!(
328            help.message.contains("Consider adding a message"),
329            "got: {}",
330            help.message
331        );
332        assert_eq!(help.spec, "pricing");
333        assert_eq!(help.effective_from, None);
334    }
335
336    #[test]
337    fn clean_spec_has_no_recommendations() {
338        let engine = load(
339            r#"spec pricing 2026-01-01
340"""
341Bulk pricing.
342"""
343
344data qty: number
345  -> minimum 0
346  -> maximum 1000000
347  -> help "Order quantity."
348
349rule total: qty
350"#,
351        );
352        assert!(engine.quality().is_empty(), "got: {:?}", engine.quality());
353    }
354
355    #[test]
356    fn number_without_bounds() {
357        let engine = load(
358            r#"spec pricing 2026-01-01
359"""
360x
361"""
362
363data qty: number
364  -> help "Order quantity."
365rule total: qty
366"#,
367        );
368        let hit = engine
369            .quality()
370            .into_iter()
371            .find(|r| {
372                r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("qty")
373            })
374            .expect("missing bounds");
375        assert!(
376            hit.message.contains("Consider adding bounds"),
377            "got: {}",
378            hit.message
379        );
380    }
381
382    #[test]
383    fn number_with_only_minimum_still_flagged() {
384        let engine = load(
385            r#"spec pricing 2026-01-01
386"""
387x
388"""
389
390data qty: number
391  -> minimum 0
392  -> help "Order quantity."
393rule total: qty
394"#,
395        );
396        assert!(
397            engine.quality().iter().any(|r| {
398                r.message.contains("no `-> maximum`")
399                    && !r.message.contains("no `-> minimum` or")
400                    && r.message.contains("qty")
401            }),
402            "only minimum must flag missing maximum: {:?}",
403            engine.quality()
404        );
405    }
406
407    #[test]
408    fn number_with_only_maximum_still_flagged() {
409        let engine = load(
410            r#"spec pricing 2026-01-01
411"""
412x
413"""
414
415data qty: number
416  -> maximum 100
417  -> help "Order quantity."
418rule total: qty
419"#,
420        );
421        assert!(
422            engine.quality().iter().any(|r| {
423                r.message.contains("no `-> minimum`")
424                    && !r.message.contains("or `-> maximum`")
425                    && r.message.contains("qty")
426            }),
427            "only maximum must flag missing minimum: {:?}",
428            engine.quality()
429        );
430    }
431
432    #[test]
433    fn number_with_min_and_max_clean() {
434        let engine = load(
435            r#"spec pricing 2026-01-01
436"""
437x
438"""
439
440data qty: number
441  -> minimum 0
442  -> maximum 100
443  -> help "Order quantity."
444rule total: qty
445"#,
446        );
447        assert!(
448            !engine
449                .quality()
450                .iter()
451                .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
452            "min+max must not flag bounds: {:?}",
453            engine.quality()
454        );
455    }
456
457    #[test]
458    fn measure_and_ratio_without_bounds() {
459        let engine = load(
460            r#"spec pricing 2026-01-01
461"""
462x
463"""
464
465data price: measure
466  -> unit eur 1
467  -> help "Unit price."
468data discount: ratio
469  -> help "Discount rate."
470rule total: price
471rule rate: discount
472"#,
473        );
474        let recs = engine.quality();
475        assert!(
476            recs.iter().any(|r| {
477                r.message.contains("no `-> minimum` or `-> maximum`") && r.message.contains("price")
478            }),
479            "measure must flag: {recs:?}"
480        );
481        assert!(
482            recs.iter().any(|r| {
483                r.message.contains("no `-> minimum` or `-> maximum`")
484                    && r.message.contains("discount")
485            }),
486            "ratio must flag: {recs:?}"
487        );
488    }
489
490    #[test]
491    fn text_not_flagged_for_bounds() {
492        let engine = load(
493            r#"spec pricing 2026-01-01
494"""
495x
496"""
497
498data status: text
499  -> help "Status."
500rule ok: status is "active"
501"#,
502        );
503        assert!(
504            !engine
505                .quality()
506                .iter()
507                .any(|r| r.message.contains("no `-> minimum` or `-> maximum`")),
508            "text must not get bounds rec: {:?}",
509            engine.quality()
510        );
511    }
512
513    #[test]
514    fn data_missing_help() {
515        let engine = load(
516            r#"spec pricing 2026-01-01
517"""
518x
519"""
520
521data qty: number
522rule total: qty
523"#,
524        );
525        let hit = engine
526            .quality()
527            .into_iter()
528            .find(|r| r.message.contains("no `-> help`") && r.message.contains("qty"))
529            .expect("missing help");
530        assert!(
531            hit.message.contains("Consider adding a message"),
532            "got: {}",
533            hit.message
534        );
535        assert_eq!(hit.spec, "pricing");
536        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
537        assert!(
538            hit.to_string().starts_with(
539                "In spec 'pricing' (effective from 2026-01-01): `qty` has no `-> help`"
540            ),
541            "Display must include effective_from, got: {}",
542            hit
543        );
544        assert!(
545            hit.to_string().contains(" at test.lemma:"),
546            "Display must append source location, got: {}",
547            hit
548        );
549    }
550
551    #[test]
552    fn text_without_options() {
553        let engine = load(
554            r#"spec pricing 2026-01-01
555"""
556x
557"""
558
559data status: text
560  -> help "Status."
561rule ok: status is "active"
562"#,
563        );
564        let hit = engine
565            .quality()
566            .into_iter()
567            .find(|r| r.message.contains("accepts any text") && r.message.contains("status"))
568            .expect("text without options");
569        assert!(
570            hit.message.contains("offer choices"),
571            "got: {}",
572            hit.message
573        );
574        assert_eq!(hit.spec, "pricing");
575        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
576    }
577
578    #[test]
579    fn veto_as_rejection_cascade() {
580        let engine = load(
581            r#"spec eligibility 2026-01-01
582"""
583Age gate.
584"""
585
586data age: number
587  -> help "Customer age."
588
589rule is_eligible: true
590  unless age < 18 then veto "Must be 18+"
591"#,
592        );
593        let hit = engine
594            .quality()
595            .into_iter()
596            .find(|r| {
597                r.message.contains("treats a yes/no outcome as veto")
598                    && r.message.contains("is_eligible")
599            })
600            .expect("veto cascade");
601        assert_eq!(hit.spec, "eligibility");
602        assert_eq!(effective_display(&hit).as_deref(), Some("2026-01-01"));
603    }
604
605    #[test]
606    fn boolean_denial_is_not_cascade() {
607        let engine = load(
608            r#"spec eligibility 2026-01-01
609"""
610Age gate.
611"""
612
613data age: number
614  -> help "Customer age."
615
616rule is_eligible: true
617  unless age < 18 then false
618"#,
619        );
620        assert!(!engine
621            .quality()
622            .iter()
623            .any(|r| r.message.contains("treats a yes/no outcome as veto")));
624    }
625
626    #[test]
627    fn stdlib_dependency_not_reported() {
628        let engine = load(
629            r#"spec ship 2026-01-01
630"""
631Weight check.
632"""
633
634uses lemma units
635
636data package_weight: units.mass
637  -> help "Package weight."
638
639rule heavy: package_weight > 20 kilogram
640"#,
641        );
642        let recs = engine.quality();
643        assert!(
644            recs.iter().all(|r| r.spec != "units"),
645            "stdlib units must not appear: {recs:?}"
646        );
647    }
648
649    #[test]
650    fn temporal_slices_distinct_by_effective_from() {
651        let engine = load(
652            r#"spec pricing 1933-01-01
653"""
654Old.
655"""
656
657data qty: number
658rule total: qty
659
660spec pricing 2026-01-01
661"""
662New.
663"""
664
665data qty: number
666rule total: qty
667"#,
668        );
669        let helps: Vec<_> = engine
670            .quality()
671            .into_iter()
672            .filter(|r| r.message.contains("no `-> help`"))
673            .collect();
674        assert_eq!(helps.len(), 2, "got: {helps:?}");
675        assert!(
676            helps.iter().any(|r| {
677                r.spec == "pricing" && effective_display(r).as_deref() == Some("1933-01-01")
678            }),
679            "missing 1933 slice: {helps:?}"
680        );
681        assert!(
682            helps.iter().any(|r| {
683                r.spec == "pricing" && effective_display(r).as_deref() == Some("2026-01-01")
684            }),
685            "missing 2026 slice: {helps:?}"
686        );
687        assert!(
688            helps
689                .iter()
690                .all(|r| !r.message.contains("1933") && !r.message.contains("2026")),
691            "message must not carry temporal identity: {helps:?}"
692        );
693        let displays: Vec<String> = helps.iter().map(|r| r.to_string()).collect();
694        assert!(
695            displays
696                .iter()
697                .any(|s| s.contains("In spec 'pricing' (effective from 1933-01-01)")),
698            "Display missing 1933: {displays:?}"
699        );
700        assert!(
701            displays
702                .iter()
703                .any(|s| s.contains("In spec 'pricing' (effective from 2026-01-01)")),
704            "Display missing 2026: {displays:?}"
705        );
706    }
707
708    #[test]
709    fn recommendation_wire_json_uses_source_not_source_location() {
710        let engine = load(
711            r#"spec pricing 2026-01-01
712"""
713x
714"""
715
716data qty: number
717rule total: qty
718"#,
719        );
720        let hit = engine
721            .quality()
722            .into_iter()
723            .find(|r| r.message.contains("no `-> help`"))
724            .expect("missing help");
725        let json = serde_json::to_value(&hit).expect("serialize");
726        assert!(json.get("source").is_some(), "wire must use source: {json}");
727        assert!(
728            json.get("source_location").is_none(),
729            "wire must not expose source_location: {json}"
730        );
731        assert_eq!(json["source"]["attribute"], serde_json::json!("test.lemma"));
732        let round: Recommendation = serde_json::from_value(json).expect("deserialize");
733        assert_eq!(round.spec, "pricing");
734        assert!(round.message.contains("no `-> help`"));
735    }
736}