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::literals::Value;
7use crate::parsing::ast::{
8    DataValue, Expression, ExpressionKind, LemmaData, LemmaRule, LemmaSpec, ParentType,
9    PrimitiveKind, TypeConstraintCommand,
10};
11use crate::parsing::source::{Source, SourceType};
12use crate::Engine;
13use std::fmt;
14
15/// Kind of structural quality Recommendation.
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17#[serde(rename_all = "snake_case", tag = "kind")]
18pub enum RecommendationKind {
19    SpecMissingCommentary,
20    SpecMissingEffectiveDate,
21    DataMissingHelp { data: String },
22    TextDataWithoutOptions { data: String },
23    OpenInputWithoutSuggestion { data: String },
24    VetoAsRejectionCascade { rule: String },
25}
26
27/// One structural quality Recommendation from [`Engine::quality`].
28#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub struct Recommendation {
30    pub kind: RecommendationKind,
31    pub repository: Option<String>,
32    pub spec: String,
33    pub source_location: Source,
34}
35
36impl fmt::Display for Recommendation {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match &self.kind {
39            RecommendationKind::SpecMissingCommentary => write!(
40                f,
41                "Spec '{}' has no commentary block. Add `\"\"\"...\"\"\"` immediately after the spec line so callers know what this policy covers.",
42                self.spec
43            ),
44            RecommendationKind::SpecMissingEffectiveDate => write!(
45                f,
46                "Spec '{}' has no effective date. If this encodes a dated policy, declare `spec {} YYYY-MM-DD` so temporal history stays answerable; if the policy is timeless, confirm with the author.",
47                self.spec, self.spec
48            ),
49            RecommendationKind::DataMissingHelp { data } => write!(
50                f,
51                "`{data}` has no `-> help`. Where does a user find this value? Confirm with the author, then document it."
52            ),
53            RecommendationKind::TextDataWithoutOptions { data } => write!(
54                f,
55                "`{data}` accepts any text. If the policy defines a closed set, declare them with `-> option`; if free text is intended, confirm with the author."
56            ),
57            RecommendationKind::OpenInputWithoutSuggestion { data } => write!(
58                f,
59                "`{data}` is an open input with no `-> suggest`. If a common default exists in the policy, declare it as a suggestion (UI hint only); otherwise confirm with the author."
60            ),
61            RecommendationKind::VetoAsRejectionCascade { rule } => write!(
62                f,
63                "`{rule}` defaults to a boolean and overrides only with veto. Denial is a valid answer (`false`), not an unanswerable question (veto). Prefer boolean sub-rules composed with `and`; confirm the intended outcome with the author."
64            ),
65        }
66    }
67}
68
69impl Engine {
70    /// Structural quality Recommendations across all loaded specs and their relationships.
71    ///
72    /// Advisory only: never affects planning or evaluation. Skips dependency
73    /// repositories (embedded stdlib and `@owner/repo` imports).
74    #[must_use]
75    pub fn quality(&self) -> Vec<Recommendation> {
76        let mut out = Vec::new();
77        for (repository, inner) in self.context.repositories().iter() {
78            if repository.dependency.is_some() {
79                continue;
80            }
81            let repo_name = repository.name.clone();
82            for (_, spec_set) in inner.iter() {
83                for spec in spec_set.iter_specs() {
84                    analyze_spec(repo_name.clone(), spec, &mut out);
85                }
86            }
87        }
88        out.sort_by(|a, b| {
89            (
90                a.repository.as_deref().unwrap_or(""),
91                a.spec.as_str(),
92                a.source_location.span.line,
93                a.source_location.span.col,
94                a.source_location.span.start,
95            )
96                .cmp(&(
97                    b.repository.as_deref().unwrap_or(""),
98                    b.spec.as_str(),
99                    b.source_location.span.line,
100                    b.source_location.span.col,
101                    b.source_location.span.start,
102                ))
103        });
104        out
105    }
106}
107
108fn analyze_spec(repository: Option<String>, spec: &LemmaSpec, out: &mut Vec<Recommendation>) {
109    let loc = spec_location(spec);
110    if spec.commentary.is_none() {
111        out.push(Recommendation {
112            kind: RecommendationKind::SpecMissingCommentary,
113            repository: repository.clone(),
114            spec: spec.name.clone(),
115            source_location: loc.clone(),
116        });
117    }
118    if spec.effective_from.is_origin() {
119        out.push(Recommendation {
120            kind: RecommendationKind::SpecMissingEffectiveDate,
121            repository: repository.clone(),
122            spec: spec.name.clone(),
123            source_location: loc,
124        });
125    }
126
127    for data in &spec.data {
128        analyze_data(repository.clone(), &spec.name, data, out);
129    }
130    for rule in &spec.rules {
131        analyze_rule(repository.clone(), &spec.name, rule, out);
132    }
133}
134
135fn spec_location(spec: &LemmaSpec) -> Source {
136    Source::new(
137        spec.source_type.clone().unwrap_or(SourceType::Volatile),
138        crate::parsing::ast::Span {
139            start: 0,
140            end: 0,
141            line: spec.start_line,
142            col: 0,
143        },
144    )
145}
146
147fn analyze_data(
148    repository: Option<String>,
149    spec_name: &str,
150    data: &LemmaData,
151    out: &mut Vec<Recommendation>,
152) {
153    let DataValue::Definition {
154        base,
155        constraints,
156        value,
157    } = &data.value
158    else {
159        return;
160    };
161
162    let name = data.reference.name.clone();
163    let constraints = constraints.as_deref().unwrap_or(&[]);
164    let has_help = constraints
165        .iter()
166        .any(|(c, _)| matches!(c, TypeConstraintCommand::Help));
167    let has_option = constraints.iter().any(|(c, _)| {
168        matches!(
169            c,
170            TypeConstraintCommand::Option | TypeConstraintCommand::Options
171        )
172    });
173    let has_suggest = constraints
174        .iter()
175        .any(|(c, _)| matches!(c, TypeConstraintCommand::Suggest));
176
177    if !has_help {
178        out.push(Recommendation {
179            kind: RecommendationKind::DataMissingHelp { data: name.clone() },
180            repository: repository.clone(),
181            spec: spec_name.to_string(),
182            source_location: data.source_location.clone(),
183        });
184    }
185
186    if is_primitive_text(base.as_ref()) && !has_option {
187        out.push(Recommendation {
188            kind: RecommendationKind::TextDataWithoutOptions { data: name.clone() },
189            repository: repository.clone(),
190            spec: spec_name.to_string(),
191            source_location: data.source_location.clone(),
192        });
193    }
194
195    if value.is_none() && !has_suggest {
196        out.push(Recommendation {
197            kind: RecommendationKind::OpenInputWithoutSuggestion { data: name },
198            repository,
199            spec: spec_name.to_string(),
200            source_location: data.source_location.clone(),
201        });
202    }
203}
204
205fn is_primitive_text(base: Option<&ParentType>) -> bool {
206    matches!(
207        base,
208        Some(ParentType::Primitive {
209            primitive: PrimitiveKind::Text
210        })
211    )
212}
213
214fn analyze_rule(
215    repository: Option<String>,
216    spec_name: &str,
217    rule: &LemmaRule,
218    out: &mut Vec<Recommendation>,
219) {
220    if !is_boolean_literal(&rule.expression) {
221        return;
222    }
223    if rule.unless_clauses.is_empty() {
224        return;
225    }
226    let all_veto = rule
227        .unless_clauses
228        .iter()
229        .all(|u| matches!(u.result.kind, ExpressionKind::Veto(_)));
230    if !all_veto {
231        return;
232    }
233    out.push(Recommendation {
234        kind: RecommendationKind::VetoAsRejectionCascade {
235            rule: rule.name.clone(),
236        },
237        repository,
238        spec: spec_name.to_string(),
239        source_location: rule.source_location.clone(),
240    });
241}
242
243fn is_boolean_literal(expr: &Expression) -> bool {
244    matches!(&expr.kind, ExpressionKind::Literal(Value::Boolean(_)))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::parsing::source::SourceType;
251
252    fn load(code: &str) -> Engine {
253        let mut engine = Engine::new();
254        engine
255            .load([(
256                SourceType::Path(std::sync::Arc::new(std::path::PathBuf::from("test.lemma"))),
257                code.to_string(),
258            )])
259            .expect("BUG: test source must load");
260        engine
261    }
262
263    fn kinds(engine: &Engine) -> Vec<RecommendationKind> {
264        engine.quality().into_iter().map(|r| r.kind).collect()
265    }
266
267    #[test]
268    fn missing_commentary_and_effective_date() {
269        let engine = load("spec pricing\ndata x: number\nrule y: x\n");
270        let ks = kinds(&engine);
271        assert!(ks.contains(&RecommendationKind::SpecMissingCommentary));
272        assert!(ks.contains(&RecommendationKind::SpecMissingEffectiveDate));
273    }
274
275    #[test]
276    fn clean_spec_has_no_recommendations() {
277        let engine = load(
278            r#"spec pricing 2026-01-01
279"""
280Bulk pricing.
281"""
282
283data qty: number
284  -> minimum 0
285  -> help "Order quantity."
286  -> suggest 10
287
288rule total: qty
289"#,
290        );
291        assert!(engine.quality().is_empty(), "got: {:?}", engine.quality());
292    }
293
294    #[test]
295    fn data_missing_help() {
296        let engine = load(
297            r#"spec pricing 2026-01-01
298"""
299x
300"""
301
302data qty: number -> suggest 1
303rule total: qty
304"#,
305        );
306        assert!(kinds(&engine).iter().any(|k| matches!(
307            k,
308            RecommendationKind::DataMissingHelp { data } if data == "qty"
309        )));
310    }
311
312    #[test]
313    fn text_without_options() {
314        let engine = load(
315            r#"spec pricing 2026-01-01
316"""
317x
318"""
319
320data status: text
321  -> help "Status."
322  -> suggest "active"
323rule ok: status is "active"
324"#,
325        );
326        assert!(kinds(&engine).iter().any(|k| matches!(
327            k,
328            RecommendationKind::TextDataWithoutOptions { data } if data == "status"
329        )));
330    }
331
332    #[test]
333    fn open_input_without_suggestion() {
334        let engine = load(
335            r#"spec pricing 2026-01-01
336"""
337x
338"""
339
340data qty: number
341  -> help "Quantity."
342rule total: qty
343"#,
344        );
345        assert!(kinds(&engine).iter().any(|k| matches!(
346            k,
347            RecommendationKind::OpenInputWithoutSuggestion { data } if data == "qty"
348        )));
349    }
350
351    #[test]
352    fn veto_as_rejection_cascade() {
353        let engine = load(
354            r#"spec eligibility 2026-01-01
355"""
356Age gate.
357"""
358
359data age: number
360  -> help "Customer age."
361  -> suggest 30
362
363rule is_eligible: true
364  unless age < 18 then veto "Must be 18+"
365"#,
366        );
367        assert!(kinds(&engine).iter().any(|k| matches!(
368            k,
369            RecommendationKind::VetoAsRejectionCascade { rule } if rule == "is_eligible"
370        )));
371    }
372
373    #[test]
374    fn boolean_denial_is_not_cascade() {
375        let engine = load(
376            r#"spec eligibility 2026-01-01
377"""
378Age gate.
379"""
380
381data age: number
382  -> help "Customer age."
383  -> suggest 30
384
385rule is_eligible: true
386  unless age < 18 then false
387"#,
388        );
389        assert!(!kinds(&engine)
390            .iter()
391            .any(|k| matches!(k, RecommendationKind::VetoAsRejectionCascade { .. })));
392    }
393
394    #[test]
395    fn stdlib_dependency_not_reported() {
396        let engine = load(
397            r#"spec ship 2026-01-01
398"""
399Weight check.
400"""
401
402uses lemma units
403
404data package_weight: units.mass
405  -> help "Package weight."
406  -> suggest 1 kilogram
407
408rule heavy: package_weight > 20 kilogram
409"#,
410        );
411        let recs = engine.quality();
412        assert!(
413            recs.iter().all(|r| r.spec != "units"),
414            "stdlib units must not appear: {recs:?}"
415        );
416    }
417}