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