Skip to main content

okf_validator/
lint.rs

1//! Opinionated bundle health checks, beyond §11 conformance.
2//!
3//! [`validate_bundle`](crate::validate_bundle) enforces only the spec's hard
4//! requirements and reports soft guidance as warnings. [`lint_bundle`] goes
5//! further: it flags the hygiene issues a continuously-authored corpus drifts
6//! into, such as orphan concepts no link or index points at, an `index.md`
7//! that has fallen behind its directory, or a verification that predates the
8//! last regeneration.
9//!
10//! Every finding is tagged with a stable rule code 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  | missing `title`                                           |
19//! | L2   | warning  | missing `description`                                     |
20//! | L3   | warning  | missing `generated` (and no legacy `timestamp`)           |
21//! | L4   | info     | no `verified` events, trust tier is `unverified`          |
22//! | L5   | warning  | legacy v0.1 `timestamp` present                           |
23//! | L6   | warning  | legacy v0.1 body `# Citations` list present               |
24//! | L7   | warning  | body is empty                                             |
25//! | L8   | warning  | body has no top-level `#` heading                         |
26//! | L9   | warning  | latest `verified.at` predates `generated.at`              |
27//! | L10  | warning  | links to a `status: deprecated` concept                   |
28//! | L11  | warning  | past `stale_after` (with `--today`)                       |
29//! | L12  | info     | `status: draft`                                           |
30//! | L13  | info     | self-link                                                 |
31//! | L14  | warning  | `title` shared with another concept                       |
32//! | L15  | warning  | orphan: no inbound links and not listed in any `index.md` |
33//! | L16  | warning  | an existing `index.md` is out of sync with its directory  |
34
35use crate::validate::{Diagnostic, Report, Severity};
36use okf_core::bundle::Bundle;
37use okf_core::concept_id::ConceptId;
38use okf_core::date::Date;
39use okf_core::document::Document;
40use okf_core::frontmatter::Frontmatter;
41use okf_core::trust::Status;
42use std::collections::{BTreeSet, HashMap};
43use std::fs;
44use std::path::{Path, PathBuf};
45
46/// Lints a loaded bundle, returning all findings.
47///
48/// Deterministic: staleness is checked for *syntax* but not against the clock.
49/// Use [`lint_bundle_at`] to also flag concepts past their `stale_after`.
50#[must_use]
51pub fn lint_bundle(bundle: &Bundle) -> Report {
52    lint_bundle_at(bundle, None)
53}
54
55/// Lints a bundle, additionally flagging concepts that are stale on `today`.
56#[must_use]
57pub fn lint_bundle_at(bundle: &Bundle, today: Option<Date>) -> Report {
58    let mut report = Report::default();
59
60    let indexed = indexed_concepts(bundle);
61    let title_counts = count_titles(bundle);
62
63    for concept in bundle.concepts() {
64        let mut cx = Cx {
65            report: &mut report,
66            path: concept.path.clone(),
67            id: concept.id.clone(),
68        };
69        let doc = &concept.document;
70        let fm = &doc.frontmatter;
71
72        check_missing_title(&mut cx, fm);
73        check_missing_description(&mut cx, fm);
74        check_missing_generated(&mut cx, fm);
75        check_unverified(&mut cx, fm);
76        check_legacy(&mut cx, doc);
77        check_empty_body(&mut cx, doc);
78        check_top_heading(&mut cx, doc);
79        check_verified_before_generated(&mut cx, fm);
80        check_links_to_deprecated(&mut cx, bundle);
81        check_staleness(&mut cx, fm, today);
82        check_draft_status(&mut cx, fm);
83        check_self_link(&mut cx, bundle);
84        check_duplicate_title(&mut cx, fm, &title_counts);
85    }
86
87    check_orphans(bundle, &indexed, &mut report);
88    check_stale_indexes(bundle, &mut report);
89
90    report
91}
92
93/// The concepts an existing `index.md` lists, resolved across every index in
94/// the bundle. Used by the orphan rule.
95fn indexed_concepts(bundle: &Bundle) -> BTreeSet<ConceptId> {
96    let mut out = BTreeSet::new();
97    for index_path in bundle.index_files() {
98        for (raw, target) in index_listed_targets(bundle, index_path) {
99            if is_concept_link(&raw) && bundle.contains(&target) {
100                out.insert(target);
101            }
102        }
103    }
104    out
105}
106
107/// Every link target an `index.md` lists, paired with the raw target as
108/// written, resolved to a concept id whether or not that concept exists in the
109/// bundle.
110///
111/// The raw target is returned alongside so callers can tell concept links from
112/// resource links: `[sql_equality.py](sql_equality.py)` resolves to an id but
113/// names a non-markdown resource, not a concept, so the stale-index rule skips
114/// it.
115fn index_listed_targets(bundle: &Bundle, index_path: &Path) -> Vec<(String, ConceptId)> {
116    let mut out = Vec::new();
117    let Some(source) = index_source_id(bundle.root(), index_path) else {
118        return out;
119    };
120    let Ok(text) = fs::read_to_string(index_path) else {
121        return out;
122    };
123    let Ok(doc) = Document::parse(&text) else {
124        return out;
125    };
126    for link in doc.links() {
127        for target in link.resolve_all(&source) {
128            out.push((link.target.clone(), target));
129        }
130    }
131    out
132}
133
134/// `true` when a raw link target names a concept (a `.md` file or a bare id)
135/// rather than a non-markdown resource such as `attester.py`.
136fn is_concept_link(raw: &str) -> bool {
137    let t = raw.trim();
138    if t.starts_with('#') || t.is_empty() {
139        return false;
140    }
141    if okf_core::links::LinkKind::External == okf_core::links::Link::classify(t) {
142        return false;
143    }
144    let before_anchor = t.split('#').next().unwrap_or(t);
145    let basename = before_anchor.rsplit('/').next().unwrap_or(before_anchor);
146    // OKF reserves the lowercase `index.md` and `log.md` filenames (§3.1), so a
147    // case-sensitive comparison is correct here, not a missing-extension bug.
148    #[allow(clippy::case_sensitive_file_extension_comparisons)]
149    {
150        basename.ends_with(".md") || !basename.contains('.')
151    }
152}
153
154/// Synthesizes the concept id an `index.md` would have if it were itself a
155/// concept, so [`Link::resolve_all`] can resolve its relative links against the
156/// index's own directory.
157///
158/// For `<root>/index.md` this returns the one-segment id `index`, whose
159/// [`ConceptId::parent`] is `None`, so relative links resolve from the bundle
160/// root. For `<root>/computations/index.md` it returns `computations/index`,
161/// whose parent is `computations`.
162fn index_source_id(bundle_root: &Path, index_path: &Path) -> Option<ConceptId> {
163    let rel = index_path.strip_prefix(bundle_root).ok()?;
164    let mut segments: Vec<String> = rel
165        .components()
166        .filter_map(|c| match c {
167            std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
168            _ => None,
169        })
170        .collect();
171    if let Some(last) = segments.last_mut()
172        && let Some(stripped) = last.strip_suffix(".md")
173    {
174        *last = stripped.to_string();
175    }
176    ConceptId::new(segments).ok()
177}
178
179/// Maps each `title` (as written) to the number of concepts that share it.
180fn count_titles(bundle: &Bundle) -> HashMap<String, usize> {
181    let mut counts: HashMap<String, usize> = HashMap::new();
182    for c in bundle.concepts() {
183        if let Some(title) = c.document.frontmatter.title() {
184            *counts.entry(title.into_owned()).or_default() += 1;
185        }
186    }
187    counts
188}
189
190/// The per-concept lint context, mirroring [`validate`](crate::validate)'s
191/// `Context`: each rule can emit a diagnostic without repeating the path and
192/// id, and every message is tagged with its rule code.
193struct Cx<'a> {
194    report: &'a mut Report,
195    path: PathBuf,
196    id: ConceptId,
197}
198
199impl Cx<'_> {
200    fn warn(&mut self, code: &'static str, message: impl Into<String>) {
201        self.push(Severity::Warning, code, message);
202    }
203
204    fn info(&mut self, code: &'static str, message: impl Into<String>) {
205        self.push(Severity::Info, code, message);
206    }
207
208    fn push(&mut self, severity: Severity, code: &'static str, message: impl Into<String>) {
209        self.report.diagnostics.push(Diagnostic {
210            severity,
211            path: Some(self.path.clone()),
212            concept: Some(self.id.clone()),
213            message: format!("[{code}] {}", message.into()),
214        });
215    }
216}
217
218fn check_missing_title(cx: &mut Cx, fm: &Frontmatter) {
219    if fm.title().is_none() {
220        cx.warn(
221            "L1",
222            "missing `title`; consumers fall back to the filename, but a human-readable \
223             title is recommended",
224        );
225    }
226}
227
228fn check_missing_description(cx: &mut Cx, fm: &Frontmatter) {
229    if fm.description().is_none() {
230        cx.warn(
231            "L2",
232            "missing `description`; a one-line summary is recommended and \
233             what `index.md` listings display",
234        );
235    }
236}
237
238fn check_missing_generated(cx: &mut Cx, fm: &Frontmatter) {
239    let has_generated_key = fm.get("generated").is_some();
240    let has_legacy_timestamp = fm.timestamp().is_some();
241    if !has_generated_key && !has_legacy_timestamp {
242        cx.warn(
243            "L3",
244            "missing `generated`; a continuously-authored corpus should record who \
245             produced the content and when",
246        );
247    }
248}
249
250fn check_unverified(cx: &mut Cx, fm: &Frontmatter) {
251    if fm.get("verified").is_none() {
252        cx.info("L4", "no `verified` events; trust tier is `unverified`");
253    }
254}
255
256fn check_legacy(cx: &mut Cx, doc: &Document) {
257    if doc.frontmatter.timestamp().is_some() {
258        cx.warn(
259            "L5",
260            "`timestamp` is a v0.1 key superseded by `generated: { by, at }`",
261        );
262    }
263    if doc.has_legacy_citations() {
264        cx.warn(
265            "L6",
266            "body `# Citations` list is superseded by `sources` + footnote attribution",
267        );
268    }
269}
270
271fn check_empty_body(cx: &mut Cx, doc: &Document) {
272    if doc.body.trim().is_empty() {
273        cx.warn(
274            "L7",
275            "body is empty; a concept should carry at least one line of prose or code",
276        );
277    }
278}
279
280fn check_top_heading(cx: &mut Cx, doc: &Document) {
281    if doc.body.trim().is_empty() {
282        return; // L7 already covers this
283    }
284    let has_top_heading = doc.body.lines().any(|l| l.trim_start().starts_with("# "));
285    if !has_top_heading {
286        cx.warn(
287            "L8",
288            "body has no top-level `#` heading; OKF docs conventionally open with one",
289        );
290    }
291}
292
293fn check_verified_before_generated(cx: &mut Cx, fm: &Frontmatter) {
294    let Some(generated) = fm.generated() else {
295        return;
296    };
297    let Some(generated_at) = generated.at.as_ref().and_then(|a| a.datetime) else {
298        return;
299    };
300    let verified = fm.verified();
301    let Some(latest) = okf_core::trust::latest_verification(&verified) else {
302        return;
303    };
304    let Some(latest_at) = latest.at.as_ref().and_then(|a| a.datetime) else {
305        return;
306    };
307    if latest_at < generated_at {
308        cx.warn(
309            "L9",
310            format!(
311                "latest verification ({latest_at}) predates `generated.at` ({generated_at}); \
312                 the current content was never re-verified"
313            ),
314        );
315    }
316}
317
318fn check_links_to_deprecated(cx: &mut Cx, bundle: &Bundle) {
319    let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
320    for link in bundle.links_from(&cx.id) {
321        if !link.exists || !warned.insert(link.target.clone()) {
322            continue;
323        }
324        if let Some(target) = bundle.get(&link.target)
325            && target.status().is_deprecated()
326        {
327            cx.warn(
328                "L10",
329                format!("links to deprecated concept `{}`", link.target),
330            );
331        }
332    }
333}
334
335fn check_staleness(cx: &mut Cx, fm: &Frontmatter, today: Option<Date>) {
336    let Some(today) = today else {
337        return;
338    };
339    let Some(stale_after) = fm.stale_after() else {
340        return;
341    };
342    if fm.is_stale_on(today) {
343        cx.warn(
344            "L11",
345            format!("stale since {stale_after} (`stale_after` passed)"),
346        );
347    }
348}
349
350fn check_draft_status(cx: &mut Cx, fm: &Frontmatter) {
351    if matches!(fm.status(), Status::Draft) {
352        cx.info(
353            "L12",
354            "`status: draft`; a draft concept is not ready for production consumption",
355        );
356    }
357}
358
359fn check_self_link(cx: &mut Cx, bundle: &Bundle) {
360    for link in bundle.links_from(&cx.id) {
361        if link.exists && link.target == cx.id {
362            cx.info(
363                "L13",
364                "self-link; a concept that links to itself usually signals a stray reference",
365            );
366            return;
367        }
368    }
369}
370
371fn check_duplicate_title(cx: &mut Cx, fm: &Frontmatter, counts: &HashMap<String, usize>) {
372    let Some(title) = fm.title() else {
373        return;
374    };
375    if counts.get(title.as_ref()).copied().unwrap_or(0) > 1 {
376        cx.warn(
377            "L14",
378            format!("`title` {title:?} is shared with another concept; titles should disambiguate"),
379        );
380    }
381}
382
383fn check_orphans(bundle: &Bundle, indexed: &BTreeSet<ConceptId>, report: &mut Report) {
384    for c in bundle.concepts() {
385        let has_backlinks = !bundle.backlinks(&c.id).is_empty();
386        let is_indexed = indexed.contains(&c.id);
387        if !has_backlinks && !is_indexed {
388            report.diagnostics.push(Diagnostic {
389                severity: Severity::Warning,
390                path: Some(c.path.clone()),
391                concept: Some(c.id.clone()),
392                message: "[L15] orphan concept: no other concept links to it and no \
393                          `index.md` lists it"
394                    .to_string(),
395            });
396        }
397    }
398}
399
400fn check_stale_indexes(bundle: &Bundle, report: &mut Report) {
401    for index_path in bundle.index_files() {
402        let Some(dir) = index_path.parent() else {
403            continue;
404        };
405        let Some(index_id) = index_source_id(bundle.root(), index_path) else {
406            continue;
407        };
408        // `None` for the root index (its directory is the bundle root, which
409        // has no parent in concept-id space); `Some(dir)` for a sub-index.
410        let index_dir = index_id.parent();
411
412        let actual: BTreeSet<ConceptId> = bundle
413            .concepts()
414            .iter()
415            .filter(|c| c.path.parent() == Some(dir))
416            .map(|c| c.id.clone())
417            .collect();
418
419        // Only links that resolve into this index's own directory count: an
420        // absolute link from the root index to `/computations/revenue.md` is
421        // navigation elsewhere, not a row this directory's index is supposed
422        // to list, so it would be noise to flag against the root's concepts.
423        // Resource links (e.g. `attester.py`) are also skipped: an index may
424        // legitimately list non-markdown files alongside its concepts.
425        let listed: BTreeSet<ConceptId> = index_listed_targets(bundle, index_path)
426            .into_iter()
427            .filter(|(raw, _)| is_concept_link(raw))
428            .map(|(_, target)| target)
429            .filter(|t| t.parent() == index_dir)
430            .collect();
431
432        let missing_from_index: Vec<String> = actual
433            .iter()
434            .filter(|c| !listed.contains(*c))
435            .map(ConceptId::to_string)
436            .collect();
437        let listed_but_not_on_disk: Vec<String> = listed
438            .iter()
439            .filter(|c| !actual.contains(*c))
440            .map(ConceptId::to_string)
441            .collect();
442
443        if missing_from_index.is_empty() && listed_but_not_on_disk.is_empty() {
444            continue;
445        }
446
447        let mut parts = Vec::new();
448        if !missing_from_index.is_empty() {
449            parts.push(format!(
450                "missing from index: {}",
451                missing_from_index.join(", ")
452            ));
453        }
454        if !listed_but_not_on_disk.is_empty() {
455            parts.push(format!(
456                "listed but not on disk: {}",
457                listed_but_not_on_disk.join(", ")
458            ));
459        }
460
461        report.diagnostics.push(Diagnostic {
462            severity: Severity::Warning,
463            path: Some(index_path.clone()),
464            concept: None,
465            message: format!(
466                "[L16] index.md is out of sync with its directory ({})",
467                parts.join("; ")
468            ),
469        });
470    }
471}