Skip to main content

okf_validator/
lint.rs

1//! Opinionated bundle health checks, beyond conformance and validation.
2//!
3//! [`validate_bundle`](crate::validate_bundle) enforces the spec's hard
4//! conformance requirements and reports material deviations, data integrity
5//! problems, broken links, temporal inconsistencies, and contract issues as
6//! validation warnings. [`lint_bundle`] goes further into opinionated
7//! authoring hygiene, markdown prose layout, code-block syntax tagging,
8//! and whitespace formatting.
9//!
10//! Every finding is tagged with a stable rule code (`L1`..`L12`) so CI can pin or
11//! silence individual checks. None of them is a conformance failure: a bundle
12//! with lint findings is still conformant if [`validate_bundle`](crate::validate_bundle) says so, which
13//! is why `okf lint` is a separate command rather than a stricter
14//! `okf validate`.
15//!
16//! | Code | Severity | Finding                                                            |
17//! |------|----------|--------------------------------------------------------------------|
18//! | L1   | warning  | body has no top-level `#` heading                                  |
19//! | L2   | info     | frontmatter keys not in canonical preferred order                  |
20//! | L3   | warning  | heading hierarchy drift (heading levels skipped or multiple `#`)   |
21//! | L4   | warning  | empty / stub section heading with no content                       |
22//! | L5   | warning  | source declared in frontmatter but never cited with footnote       |
23//! | L6   | info     | non-standard actor identity in generated, verified, or author      |
24//! | L7   | warning  | `# Computation` code block missing language tag                    |
25//! | L8   | info     | trailing whitespace or excess blank lines in markdown body         |
26//! | L9   | warning  | orphan: no inbound links and not listed in any `index.md`          |
27//! | L10  | info     | self-link (concept links to itself)                                |
28//! | L11  | info     | no `verified` events, trust tier is `unverified`                   |
29//! | L12  | info     | `status: draft`                                                    |
30//! | L13  | warning  | `okf_version` in root index.md is unquoted                         |
31
32use crate::validate::{Diagnostic, Report, Severity, index_listed_targets, is_concept_link};
33use okf_core::bundle::Bundle;
34use okf_core::concept_id::ConceptId;
35use okf_core::date::Date;
36use okf_core::document::Document;
37use okf_core::frontmatter::Frontmatter;
38use okf_core::trust::Status;
39use std::collections::BTreeSet;
40use std::path::PathBuf;
41
42/// Lints a loaded bundle, returning all findings.
43///
44/// Deterministic: staleness and temporal checks are handled during validation.
45/// Use [`lint_bundle_at`] for consistency with the validator API.
46#[must_use]
47pub fn lint_bundle(bundle: &Bundle) -> Report {
48    lint_bundle_at(bundle, None)
49}
50
51/// Lints a bundle, returning all opinionated formatting and style findings.
52#[must_use]
53pub fn lint_bundle_at(bundle: &Bundle, _today: Option<Date>) -> Report {
54    let mut report = Report::default();
55
56    let indexed = indexed_concepts(bundle);
57
58    for concept in bundle.concepts() {
59        let mut cx = Cx {
60            report: &mut report,
61            path: concept.path.clone(),
62            id: concept.id.clone(),
63        };
64        let doc = &concept.document;
65        let fm = &doc.frontmatter;
66
67        check_top_heading(&mut cx, doc);
68        check_key_order(&mut cx, fm);
69        check_heading_hierarchy(&mut cx, doc);
70        check_empty_headings(&mut cx, doc);
71        check_unused_sources(&mut cx, doc);
72        check_non_standard_actor(&mut cx, fm);
73        check_computation_block_formatting(&mut cx, doc);
74        check_whitespace(&mut cx, doc);
75        check_self_link(&mut cx, bundle);
76        check_unverified(&mut cx, fm);
77        check_draft_status(&mut cx, fm);
78    }
79
80    check_orphans(bundle, &indexed, &mut report);
81    check_root_index_version(bundle, &mut report);
82
83    report
84}
85
86/// The concepts an existing `index.md` lists, resolved across every index in
87/// the bundle. Used by the orphan rule.
88fn indexed_concepts(bundle: &Bundle) -> BTreeSet<ConceptId> {
89    let mut out = BTreeSet::new();
90    for index_path in bundle.index_files() {
91        for (raw, target) in index_listed_targets(bundle, index_path) {
92            if is_concept_link(&raw) && bundle.contains(&target) {
93                out.insert(target);
94            }
95        }
96    }
97    out
98}
99
100/// The per-concept lint context, mirroring [`validate`](crate::validate)'s
101/// `Context`: each rule can emit a diagnostic without repeating the path and
102/// id, and every message is tagged with its rule code.
103struct Cx<'a> {
104    report: &'a mut Report,
105    path: PathBuf,
106    id: ConceptId,
107}
108
109impl Cx<'_> {
110    fn warn(&mut self, code: &'static str, message: impl Into<String>) {
111        self.push(Severity::Warning, code, message);
112    }
113
114    fn info(&mut self, code: &'static str, message: impl Into<String>) {
115        self.push(Severity::Info, code, message);
116    }
117
118    fn push(&mut self, severity: Severity, code: &'static str, message: impl Into<String>) {
119        let fixable = is_fixable_lint(code);
120        self.report.diagnostics.push(Diagnostic {
121            severity,
122            path: Some(self.path.clone()),
123            concept: Some(self.id.clone()),
124            message: format!("[{code}] {}", message.into()),
125            fixable,
126        });
127    }
128}
129
130/// Whether a lint finding can be automatically remediated by `okf fix`.
131const fn is_fixable_lint(code: &str) -> bool {
132    matches!(code.as_bytes(), b"L1" | b"L2" | b"L7" | b"L8" | b"L13")
133}
134
135fn check_unverified(cx: &mut Cx, fm: &Frontmatter) {
136    if fm.get("verified").is_none() {
137        cx.info("L11", "no `verified` events; trust tier is `unverified`");
138    }
139}
140
141fn check_top_heading(cx: &mut Cx, doc: &Document) {
142    if doc.body.trim().is_empty() {
143        return;
144    }
145    let has_top_heading = doc.body.lines().any(|l| l.trim_start().starts_with("# "));
146    if !has_top_heading {
147        cx.warn(
148            "L1",
149            "body has no top-level `#` heading; OKF docs conventionally open with one",
150        );
151    }
152}
153
154fn check_draft_status(cx: &mut Cx, fm: &Frontmatter) {
155    if matches!(fm.status(), Status::Draft) {
156        cx.info(
157            "L12",
158            "`status: draft`; a draft concept is not ready for production consumption",
159        );
160    }
161}
162
163fn check_self_link(cx: &mut Cx, bundle: &Bundle) {
164    for link in bundle.links_from(&cx.id) {
165        if link.exists && link.target == cx.id {
166            cx.info(
167                "L10",
168                "self-link; a concept that links to itself usually signals a stray reference",
169            );
170            return;
171        }
172    }
173}
174
175fn check_orphans(bundle: &Bundle, indexed: &BTreeSet<ConceptId>, report: &mut Report) {
176    for c in bundle.concepts() {
177        let has_backlinks = !bundle.backlinks(&c.id).is_empty();
178        let is_indexed = indexed.contains(&c.id);
179        if !has_backlinks && !is_indexed {
180            report.diagnostics.push(Diagnostic {
181                severity: Severity::Warning,
182                path: Some(c.path.clone()),
183                concept: Some(c.id.clone()),
184                message: "[L9] orphan concept: no other concept links to it and no \
185                          `index.md` lists it"
186                    .to_string(),
187                fixable: false,
188            });
189        }
190    }
191}
192
193fn check_key_order(cx: &mut Cx, fm: &Frontmatter) {
194    let keys: Vec<&str> = fm.as_mapping().keys().collect();
195    if keys.len() < 2 {
196        return;
197    }
198    let mut last_rank = None;
199    for key in keys {
200        if let Some(rank) = okf_core::frontmatter::PREFERRED_KEY_ORDER
201            .iter()
202            .position(|&k| k == key)
203        {
204            if let Some(prev) = last_rank
205                && rank < prev
206            {
207                cx.info(
208                    "L2",
209                    "frontmatter keys are not in canonical order (run `okf fmt` to normalize)",
210                );
211                return;
212            }
213            last_rank = Some(rank);
214        }
215    }
216}
217
218fn check_heading_hierarchy(cx: &mut Cx, doc: &Document) {
219    let headings = okf_core::extract_headings(&doc.body);
220    if headings.is_empty() {
221        return;
222    }
223
224    let is_attested = doc.frontmatter.is_attested_computation();
225    let mut h1_count = 0;
226    let mut prev_level = 0;
227
228    for h in &headings {
229        if h.level == 1 {
230            h1_count += 1;
231            if h1_count > 1 && !(is_attested && h1_count == 2 && h.text == "Computation") {
232                cx.warn(
233                    "L3",
234                    format!(
235                        "multiple top-level `#` headings found (heading `{}` at line {})",
236                        h.text, h.line_num
237                    ),
238                );
239            }
240        }
241
242        if prev_level > 0 && h.level > prev_level + 1 {
243            cx.warn(
244                "L3",
245                format!(
246                    "heading level skipped: `{}` jumps from h{prev_level} to h{}",
247                    h.text, h.level
248                ),
249            );
250        }
251        prev_level = h.level;
252    }
253}
254
255fn check_empty_headings(cx: &mut Cx, doc: &Document) {
256    let lines: Vec<&str> = doc.body.lines().collect();
257    let headings = okf_core::extract_headings(&doc.body);
258
259    for (k, h) in headings.iter().enumerate() {
260        let content_end = match headings.get(k + 1) {
261            Some(next_h) => {
262                if next_h.level > h.level {
263                    continue;
264                }
265                next_h.line_index
266            }
267            None => lines.len(),
268        };
269
270        let has_content = (h.line_index + 1..content_end).any(|idx| {
271            let l = lines[idx].trim();
272            !l.is_empty() && !l.starts_with("<!--")
273        });
274
275        if !has_content {
276            cx.warn("L4", format!("heading `{}` has no content", h.text));
277        }
278    }
279}
280
281fn check_unused_sources(cx: &mut Cx, doc: &Document) {
282    let sources = doc.frontmatter.sources();
283    if sources.is_empty() {
284        return;
285    }
286    let attributions = doc.attributions();
287    for source in sources {
288        if let Some(id) = &source.id {
289            let is_cited = attributions.iter().any(|a| a.label == *id)
290                || doc.body.contains(&format!("[^{id}]"));
291            if !is_cited {
292                cx.warn(
293                    "L5",
294                    format!(
295                        "source `{id}` is declared in frontmatter but never cited with footnote `[^{id}]`",
296                    ),
297                );
298            }
299        }
300    }
301}
302
303fn check_non_standard_actor(cx: &mut Cx, fm: &Frontmatter) {
304    if let Some(generated) = fm.generated()
305        && let Some(by) = &generated.by
306        && matches!(by.kind(), okf_core::ActorKind::Other)
307    {
308        cx.info(
309            "L6",
310            format!(
311                "actor `{by}` in `generated.by` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
312            ),
313        );
314    }
315    for verification in fm.verified() {
316        if let Some(by) = &verification.by
317            && matches!(by.kind(), okf_core::ActorKind::Other)
318        {
319            cx.info(
320                "L6",
321                format!(
322                    "actor `{by}` in `verified.by` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
323                ),
324            );
325        }
326    }
327    for source in fm.sources() {
328        if let Some(author) = &source.author
329            && matches!(author.kind(), okf_core::ActorKind::Other)
330        {
331            cx.info(
332                "L6",
333                format!(
334                    "author `{author}` in `sources.author` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
335                ),
336            );
337        }
338    }
339}
340
341fn check_computation_block_formatting(cx: &mut Cx, doc: &Document) {
342    let Some(contract) = doc.attested_computation() else {
343        return;
344    };
345    if let okf_core::computation::ComputationSource::Inline(inline) = &contract.computation
346        && inline.language.is_none()
347    {
348        cx.warn(
349            "L7",
350            "`# Computation` code block is missing a syntax language tag (e.g. ` ```python ` or ` ```sql `)",
351        );
352    }
353}
354
355fn check_whitespace(cx: &mut Cx, doc: &Document) {
356    let mut trailing_count = 0;
357    let mut first_trailing_line = 0;
358    let mut excess_blank = false;
359    let mut consecutive_blank = 0;
360
361    for (i, line) in doc.body.lines().enumerate() {
362        if line.ends_with(char::is_whitespace) {
363            trailing_count += 1;
364            if first_trailing_line == 0 {
365                first_trailing_line = i + 1;
366            }
367        }
368        if line.trim().is_empty() {
369            consecutive_blank += 1;
370            if consecutive_blank > 2 {
371                excess_blank = true;
372            }
373        } else {
374            consecutive_blank = 0;
375        }
376    }
377
378    let body_trimmed = doc.body.trim_end_matches(['\n', '\r']);
379    let has_trailing_blank_lines = doc.body.len() > body_trimmed.len() + 1
380        && doc.body[body_trimmed.len()..]
381            .chars()
382            .filter(|&c| c == '\n')
383            .count()
384            > 2;
385
386    if trailing_count > 0 {
387        cx.info(
388            "L8",
389            format!(
390                "trailing whitespace found on {trailing_count} line(s) in markdown body (first at line {first_trailing_line})"
391            ),
392        );
393    } else if excess_blank || has_trailing_blank_lines {
394        cx.info(
395            "L8",
396            "excess consecutive blank lines found in markdown body",
397        );
398    }
399}
400
401fn check_root_index_version(bundle: &Bundle, report: &mut Report) {
402    let root_index = bundle.root().join("index.md");
403    if !root_index.exists() {
404        return;
405    }
406    let Ok(text) = std::fs::read_to_string(&root_index) else {
407        return;
408    };
409    let Ok(doc) = Document::parse(&text) else {
410        return;
411    };
412    if let Some(val) = doc.frontmatter.get("okf_version")
413        && !matches!(val, okf_core::yaml::Value::String(_))
414    {
415        report.diagnostics.push(Diagnostic {
416            severity: Severity::Warning,
417            path: Some(root_index),
418            concept: None,
419            message: "[L13] `okf_version` in root index.md is unquoted; YAML parses unquoted decimal numbers as floats".to_string(),
420            fixable: true,
421        });
422    }
423}