1use 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#[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#[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 #[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}