Skip to main content

okf_validator/
lint.rs

1//! Opinionated bundle health checks, beyond 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//! | L17  | warning  | broken link to a concept that does not exist                       |
35//! | L18  | info     | frontmatter keys not in canonical preferred order                  |
36//! | L19  | warning  | heading hierarchy drift (heading levels skipped or multiple `#`)   |
37//! | L20  | warning  | empty / stub section heading with no content                       |
38//! | L21  | info     | source declared in frontmatter but never cited with footnote       |
39//! | L22  | warning  | circular concept derivation in sources graph                       |
40//! | L23  | info     | non-standard actor identity in generated, verified, or author      |
41//! | L24  | warning  | timestamp in generated, verified, or sources is in the future      |
42//! | L25  | warning  | `executor`, `attester`, or `computation` resource missing on disk  |
43//! | L26  | warning  | `# Computation` code block missing language tag                    |
44//! | L27  | warning  | duplicate date heading in `log.md`                                 |
45//! | L28  | info     | trailing whitespace or excess blank lines in markdown body         |
46
47use crate::validate::{Diagnostic, Report, Severity};
48use okf_core::bundle::Bundle;
49use okf_core::concept_id::ConceptId;
50use okf_core::date::Date;
51use okf_core::document::Document;
52use okf_core::frontmatter::Frontmatter;
53use okf_core::trust::Status;
54use std::collections::{BTreeSet, HashMap};
55use std::fs;
56use std::path::{Path, PathBuf};
57
58/// Lints a loaded bundle, returning all findings.
59///
60/// Deterministic: staleness is checked for *syntax* but not against the clock.
61/// Use [`lint_bundle_at`] to also flag concepts past their `stale_after`.
62#[must_use]
63pub fn lint_bundle(bundle: &Bundle) -> Report {
64    lint_bundle_at(bundle, None)
65}
66
67/// Lints a bundle, additionally flagging concepts that are stale on `today`.
68#[must_use]
69pub fn lint_bundle_at(bundle: &Bundle, today: Option<Date>) -> Report {
70    let mut report = Report::default();
71
72    let indexed = indexed_concepts(bundle);
73    let title_counts = count_titles(bundle);
74
75    for concept in bundle.concepts() {
76        let mut cx = Cx {
77            report: &mut report,
78            path: concept.path.clone(),
79            id: concept.id.clone(),
80        };
81        let doc = &concept.document;
82        let fm = &doc.frontmatter;
83
84        check_missing_title(&mut cx, fm);
85        check_missing_description(&mut cx, fm);
86        check_missing_generated(&mut cx, fm);
87        check_unverified(&mut cx, fm);
88        check_legacy(&mut cx, doc);
89        check_empty_body(&mut cx, doc);
90        check_top_heading(&mut cx, doc);
91        check_verified_before_generated(&mut cx, fm);
92        check_links_to_deprecated(&mut cx, bundle);
93        check_staleness(&mut cx, fm, today);
94        check_draft_status(&mut cx, fm);
95        check_self_link(&mut cx, bundle);
96        check_broken_links(&mut cx, bundle);
97        check_duplicate_title(&mut cx, fm, &title_counts);
98        check_key_order(&mut cx, fm);
99        check_heading_hierarchy(&mut cx, doc);
100        check_empty_headings(&mut cx, doc);
101        check_unused_sources(&mut cx, doc);
102        check_non_standard_actor(&mut cx, fm);
103        check_future_timestamps(&mut cx, fm, today);
104        check_attestation_resources(&mut cx, bundle, doc);
105        check_computation_block_formatting(&mut cx, doc);
106        check_whitespace(&mut cx, doc);
107    }
108
109    check_orphans(bundle, &indexed, &mut report);
110    check_stale_indexes(bundle, &mut report);
111    check_circular_derivation(bundle, &mut report);
112    check_duplicate_log_dates(bundle, &mut report);
113
114    report
115}
116
117/// The concepts an existing `index.md` lists, resolved across every index in
118/// the bundle. Used by the orphan rule.
119fn indexed_concepts(bundle: &Bundle) -> BTreeSet<ConceptId> {
120    let mut out = BTreeSet::new();
121    for index_path in bundle.index_files() {
122        for (raw, target) in index_listed_targets(bundle, index_path) {
123            if is_concept_link(&raw) && bundle.contains(&target) {
124                out.insert(target);
125            }
126        }
127    }
128    out
129}
130
131/// Every link target an `index.md` lists, paired with the raw target as
132/// written, resolved to a concept id whether or not that concept exists in the
133/// bundle.
134///
135/// The raw target is returned alongside so callers can tell concept links from
136/// resource links: `[sql_equality.py](sql_equality.py)` resolves to an id but
137/// names a non-markdown resource, not a concept, so the stale-index rule skips
138/// it.
139fn index_listed_targets(bundle: &Bundle, index_path: &Path) -> Vec<(String, ConceptId)> {
140    let mut out = Vec::new();
141    let Some(source) = index_source_id(bundle.root(), index_path) else {
142        return out;
143    };
144    let Ok(text) = fs::read_to_string(index_path) else {
145        return out;
146    };
147    let Ok(doc) = Document::parse(&text) else {
148        return out;
149    };
150    for link in doc.links() {
151        for target in link.resolve_all(&source) {
152            out.push((link.target.clone(), target));
153        }
154    }
155    out
156}
157
158/// `true` when a raw link target names a concept (a `.md` file or a bare id)
159/// rather than a non-markdown resource such as `attester.py`.
160fn is_concept_link(raw: &str) -> bool {
161    let t = raw.trim();
162    if t.starts_with('#') || t.is_empty() {
163        return false;
164    }
165    if okf_core::links::LinkKind::External == okf_core::links::Link::classify(t) {
166        return false;
167    }
168    let before_anchor = t.split('#').next().unwrap_or(t);
169    let basename = before_anchor.rsplit('/').next().unwrap_or(before_anchor);
170    // OKF reserves the lowercase `index.md` and `log.md` filenames, so a
171    // case-sensitive comparison is correct here, not a missing-extension bug.
172    #[allow(clippy::case_sensitive_file_extension_comparisons)]
173    {
174        basename.ends_with(".md") || !basename.contains('.')
175    }
176}
177
178/// Synthesizes the concept id an `index.md` would have if it were itself a
179/// concept, so [`Link::resolve_all`] can resolve its relative links against the
180/// index's own directory.
181///
182/// For `<root>/index.md` this returns the one-segment id `index`, whose
183/// [`ConceptId::parent`] is `None`, so relative links resolve from the bundle
184/// root. For `<root>/computations/index.md` it returns `computations/index`,
185/// whose parent is `computations`.
186fn index_source_id(bundle_root: &Path, index_path: &Path) -> Option<ConceptId> {
187    let rel = index_path.strip_prefix(bundle_root).ok()?;
188    let mut segments: Vec<String> = rel
189        .components()
190        .filter_map(|c| match c {
191            std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
192            _ => None,
193        })
194        .collect();
195    if let Some(last) = segments.last_mut()
196        && let Some(stripped) = last.strip_suffix(".md")
197    {
198        *last = stripped.to_string();
199    }
200    ConceptId::new(segments).ok()
201}
202
203/// Maps each `title` (as written) to the number of concepts that share it.
204fn count_titles(bundle: &Bundle) -> HashMap<String, usize> {
205    let mut counts: HashMap<String, usize> = HashMap::new();
206    for c in bundle.concepts() {
207        if let Some(title) = c.document.frontmatter.title() {
208            *counts.entry(title.into_owned()).or_default() += 1;
209        }
210    }
211    counts
212}
213
214/// The per-concept lint context, mirroring [`validate`](crate::validate)'s
215/// `Context`: each rule can emit a diagnostic without repeating the path and
216/// id, and every message is tagged with its rule code.
217struct Cx<'a> {
218    report: &'a mut Report,
219    path: PathBuf,
220    id: ConceptId,
221}
222
223impl Cx<'_> {
224    fn warn(&mut self, code: &'static str, message: impl Into<String>) {
225        self.push(Severity::Warning, code, message);
226    }
227
228    fn info(&mut self, code: &'static str, message: impl Into<String>) {
229        self.push(Severity::Info, code, message);
230    }
231
232    fn push(&mut self, severity: Severity, code: &'static str, message: impl Into<String>) {
233        let fixable = is_fixable_lint(code);
234        self.report.diagnostics.push(Diagnostic {
235            severity,
236            path: Some(self.path.clone()),
237            concept: Some(self.id.clone()),
238            message: format!("[{code}] {}", message.into()),
239            fixable,
240        });
241    }
242}
243
244/// Whether a lint finding can be automatically remediated by `okf fix`.
245const fn is_fixable_lint(code: &str) -> bool {
246    matches!(
247        code.as_bytes(),
248        b"L1" | b"L3" | b"L5" | b"L6" | b"L8" | b"L16" | b"L18" | b"L26" | b"L27" | b"L28"
249    )
250}
251
252fn check_missing_title(cx: &mut Cx, fm: &Frontmatter) {
253    if fm.title().is_none() {
254        cx.warn(
255            "L1",
256            "missing `title`; consumers fall back to the filename, but a human-readable \
257             title is recommended",
258        );
259    }
260}
261
262fn check_missing_description(cx: &mut Cx, fm: &Frontmatter) {
263    if fm.description().is_none() {
264        cx.warn(
265            "L2",
266            "missing `description`; a one-line summary is recommended and \
267             what `index.md` listings display",
268        );
269    }
270}
271
272fn check_missing_generated(cx: &mut Cx, fm: &Frontmatter) {
273    let has_generated_key = fm.get("generated").is_some();
274    let has_legacy_timestamp = fm.timestamp().is_some();
275    if !has_generated_key && !has_legacy_timestamp {
276        cx.warn(
277            "L3",
278            "missing `generated`; a continuously-authored corpus should record who \
279             produced the content and when",
280        );
281    }
282}
283
284fn check_unverified(cx: &mut Cx, fm: &Frontmatter) {
285    if fm.get("verified").is_none() {
286        cx.info("L4", "no `verified` events; trust tier is `unverified`");
287    }
288}
289
290fn check_legacy(cx: &mut Cx, doc: &Document) {
291    if doc.frontmatter.timestamp().is_some() {
292        cx.warn(
293            "L5",
294            "`timestamp` is a v0.1 key superseded by `generated: { by, at }`",
295        );
296    }
297    if doc.has_legacy_citations() {
298        cx.warn(
299            "L6",
300            "body `# Citations` list is superseded by `sources` + footnote attribution",
301        );
302    }
303}
304
305fn check_empty_body(cx: &mut Cx, doc: &Document) {
306    if doc.body.trim().is_empty() {
307        cx.warn(
308            "L7",
309            "body is empty; a concept should carry at least one line of prose or code",
310        );
311    }
312}
313
314fn check_top_heading(cx: &mut Cx, doc: &Document) {
315    if doc.body.trim().is_empty() {
316        return; // L7 already covers this
317    }
318    let has_top_heading = doc.body.lines().any(|l| l.trim_start().starts_with("# "));
319    if !has_top_heading {
320        cx.warn(
321            "L8",
322            "body has no top-level `#` heading; OKF docs conventionally open with one",
323        );
324    }
325}
326
327fn check_verified_before_generated(cx: &mut Cx, fm: &Frontmatter) {
328    let Some(generated) = fm.generated() else {
329        return;
330    };
331    let Some(generated_at) = generated.at.as_ref().and_then(|a| a.datetime) else {
332        return;
333    };
334    let verified = fm.verified();
335    let Some(latest) = okf_core::trust::latest_verification(&verified) else {
336        return;
337    };
338    let Some(latest_at) = latest.at.as_ref().and_then(|a| a.datetime) else {
339        return;
340    };
341    if latest_at < generated_at {
342        cx.warn(
343            "L9",
344            format!(
345                "latest verification ({latest_at}) predates `generated.at` ({generated_at}); \
346                 the current content was never re-verified"
347            ),
348        );
349    }
350}
351
352fn check_links_to_deprecated(cx: &mut Cx, bundle: &Bundle) {
353    let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
354    for link in bundle.links_from(&cx.id) {
355        if !link.exists || !warned.insert(link.target.clone()) {
356            continue;
357        }
358        if let Some(target) = bundle.get(&link.target)
359            && target.status().is_deprecated()
360        {
361            cx.warn(
362                "L10",
363                format!("links to deprecated concept `{}`", link.target),
364            );
365        }
366    }
367}
368
369fn check_staleness(cx: &mut Cx, fm: &Frontmatter, today: Option<Date>) {
370    let Some(today) = today else {
371        return;
372    };
373    let Some(stale_after) = fm.stale_after() else {
374        return;
375    };
376    if fm.is_stale_on(today) {
377        cx.warn(
378            "L11",
379            format!("stale since {stale_after} (`stale_after` passed)"),
380        );
381    }
382}
383
384fn check_draft_status(cx: &mut Cx, fm: &Frontmatter) {
385    if matches!(fm.status(), Status::Draft) {
386        cx.info(
387            "L12",
388            "`status: draft`; a draft concept is not ready for production consumption",
389        );
390    }
391}
392
393fn check_self_link(cx: &mut Cx, bundle: &Bundle) {
394    for link in bundle.links_from(&cx.id) {
395        if link.exists && link.target == cx.id {
396            cx.info(
397                "L13",
398                "self-link; a concept that links to itself usually signals a stray reference",
399            );
400            return;
401        }
402    }
403}
404
405fn check_broken_links(cx: &mut Cx, bundle: &Bundle) {
406    for link in bundle.links_from(&cx.id) {
407        if !link.exists {
408            cx.warn(
409                "L17",
410                format!(
411                    "broken link to `{}` (target concept does not exist)",
412                    link.raw
413                ),
414            );
415        }
416    }
417}
418
419fn check_duplicate_title(cx: &mut Cx, fm: &Frontmatter, counts: &HashMap<String, usize>) {
420    let Some(title) = fm.title() else {
421        return;
422    };
423    if counts.get(title.as_ref()).copied().unwrap_or(0) > 1 {
424        cx.warn(
425            "L14",
426            format!("`title` {title:?} is shared with another concept; titles should disambiguate"),
427        );
428    }
429}
430
431fn check_orphans(bundle: &Bundle, indexed: &BTreeSet<ConceptId>, report: &mut Report) {
432    for c in bundle.concepts() {
433        let has_backlinks = !bundle.backlinks(&c.id).is_empty();
434        let is_indexed = indexed.contains(&c.id);
435        if !has_backlinks && !is_indexed {
436            report.diagnostics.push(Diagnostic {
437                severity: Severity::Warning,
438                path: Some(c.path.clone()),
439                concept: Some(c.id.clone()),
440                message: "[L15] orphan concept: no other concept links to it and no \
441                          `index.md` lists it"
442                    .to_string(),
443                fixable: false,
444            });
445        }
446    }
447}
448
449fn check_stale_indexes(bundle: &Bundle, report: &mut Report) {
450    for index_path in bundle.index_files() {
451        let Some(dir) = index_path.parent() else {
452            continue;
453        };
454        let Some(index_id) = index_source_id(bundle.root(), index_path) else {
455            continue;
456        };
457        // `None` for the root index (its directory is the bundle root, which
458        // has no parent in concept-id space); `Some(dir)` for a sub-index.
459        let index_dir = index_id.parent();
460
461        let actual: BTreeSet<ConceptId> = bundle
462            .concepts()
463            .iter()
464            .filter(|c| c.path.parent() == Some(dir))
465            .map(|c| c.id.clone())
466            .collect();
467
468        // Only links that resolve into this index's own directory count: an
469        // absolute link from the root index to `/computations/revenue.md` is
470        // navigation elsewhere, not a row this directory's index is supposed
471        // to list, so it would be noise to flag against the root's concepts.
472        // Resource links (e.g. `attester.py`) are also skipped: an index may
473        // legitimately list non-markdown files alongside its concepts.
474        let listed: BTreeSet<ConceptId> = index_listed_targets(bundle, index_path)
475            .into_iter()
476            .filter(|(raw, _)| is_concept_link(raw))
477            .map(|(_, target)| target)
478            .filter(|t| t.parent() == index_dir)
479            .collect();
480
481        let missing_from_index: Vec<String> = actual
482            .iter()
483            .filter(|c| !listed.contains(*c))
484            .map(ConceptId::to_string)
485            .collect();
486        let listed_but_not_on_disk: Vec<String> = listed
487            .iter()
488            .filter(|c| !actual.contains(*c))
489            .map(ConceptId::to_string)
490            .collect();
491
492        if missing_from_index.is_empty() && listed_but_not_on_disk.is_empty() {
493            continue;
494        }
495
496        let mut parts = Vec::new();
497        if !missing_from_index.is_empty() {
498            parts.push(format!(
499                "missing from index: {}",
500                missing_from_index.join(", ")
501            ));
502        }
503        if !listed_but_not_on_disk.is_empty() {
504            parts.push(format!(
505                "listed but not on disk: {}",
506                listed_but_not_on_disk.join(", ")
507            ));
508        }
509
510        report.diagnostics.push(Diagnostic {
511            severity: Severity::Warning,
512            path: Some(index_path.clone()),
513            concept: None,
514            message: format!(
515                "[L16] index.md is out of sync with its directory ({})",
516                parts.join("; ")
517            ),
518            fixable: true,
519        });
520    }
521}
522
523fn check_key_order(cx: &mut Cx, fm: &Frontmatter) {
524    let keys: Vec<&str> = fm.as_mapping().keys().collect();
525    if keys.len() < 2 {
526        return;
527    }
528    let mut last_rank = None;
529    for key in keys {
530        if let Some(rank) = okf_core::frontmatter::PREFERRED_KEY_ORDER
531            .iter()
532            .position(|&k| k == key)
533        {
534            if let Some(prev) = last_rank
535                && rank < prev
536            {
537                cx.info(
538                    "L18",
539                    "frontmatter keys are not in canonical order (run `okf fmt` to normalize)",
540                );
541                return;
542            }
543            last_rank = Some(rank);
544        }
545    }
546}
547
548struct MarkdownHeading<'a> {
549    level: usize,
550    text: &'a str,
551    line_num: usize,
552}
553
554fn parse_heading_line(line: &str) -> Option<(usize, &str)> {
555    let t = line.trim_start();
556    if !t.starts_with('#') {
557        return None;
558    }
559    let count = t.chars().take_while(|&c| c == '#').count();
560    if (1..=6).contains(&count) && t[count..].starts_with(' ') {
561        Some((count, t[count..].trim()))
562    } else {
563        None
564    }
565}
566
567fn parse_markdown_headings(body: &str) -> Vec<MarkdownHeading<'_>> {
568    let mut headings = Vec::new();
569    let mut in_code_block = false;
570
571    for (i, line) in body.lines().enumerate() {
572        let trimmed = line.trim();
573        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
574            in_code_block = !in_code_block;
575            continue;
576        }
577        if in_code_block {
578            continue;
579        }
580        if let Some((level, text)) = parse_heading_line(trimmed) {
581            headings.push(MarkdownHeading {
582                level,
583                text,
584                line_num: i + 1,
585            });
586        }
587    }
588    headings
589}
590
591fn check_heading_hierarchy(cx: &mut Cx, doc: &Document) {
592    let headings = parse_markdown_headings(&doc.body);
593    if headings.is_empty() {
594        return;
595    }
596
597    let is_attested = doc.frontmatter.is_attested_computation();
598    let mut h1_count = 0;
599    let mut prev_level = 0;
600
601    for h in &headings {
602        if h.level == 1 {
603            h1_count += 1;
604            if h1_count > 1 && !(is_attested && h1_count == 2 && h.text == "Computation") {
605                cx.warn(
606                    "L19",
607                    format!(
608                        "multiple top-level `#` headings found (heading `{}` at line {})",
609                        h.text, h.line_num
610                    ),
611                );
612            }
613        }
614
615        if prev_level > 0 && h.level > prev_level + 1 {
616            cx.warn(
617                "L19",
618                format!(
619                    "heading level skipped: `{}` jumps from h{prev_level} to h{}",
620                    h.text, h.level
621                ),
622            );
623        }
624        prev_level = h.level;
625    }
626}
627
628fn check_empty_headings(cx: &mut Cx, doc: &Document) {
629    let lines: Vec<&str> = doc.body.lines().collect();
630    let mut in_code_block = false;
631
632    let mut headings_with_line: Vec<(usize, usize, &str)> = Vec::new();
633    for (i, line) in lines.iter().enumerate() {
634        let trimmed = line.trim();
635        if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
636            in_code_block = !in_code_block;
637            continue;
638        }
639        if in_code_block {
640            continue;
641        }
642        if let Some((level, text)) = parse_heading_line(trimmed) {
643            headings_with_line.push((i, level, text));
644        }
645    }
646
647    for (k, &(line_idx, level, text)) in headings_with_line.iter().enumerate() {
648        let next_heading = headings_with_line
649            .get(k + 1)
650            .map(|&(next_idx, next_level, _)| (next_idx, next_level));
651
652        let content_end = match next_heading {
653            Some((next_idx, next_level)) => {
654                if next_level > level {
655                    continue;
656                }
657                next_idx
658            }
659            None => lines.len(),
660        };
661
662        let has_content = (line_idx + 1..content_end).any(|idx| {
663            let l = lines[idx].trim();
664            !l.is_empty() && !l.starts_with("<!--")
665        });
666
667        if !has_content {
668            cx.warn("L20", format!("heading `{text}` has no content"));
669        }
670    }
671}
672
673fn check_unused_sources(cx: &mut Cx, doc: &Document) {
674    let sources = doc.frontmatter.sources();
675    if sources.is_empty() {
676        return;
677    }
678    let attributions = doc.attributions();
679    for source in sources {
680        if let Some(id) = &source.id {
681            let is_cited = attributions.iter().any(|a| a.label == *id)
682                || doc.body.contains(&format!("[^{id}]"));
683            if !is_cited {
684                cx.info(
685                    "L21",
686                    format!(
687                        "source `{id}` is declared in frontmatter but never cited with footnote `[^{id}]`",
688                    ),
689                );
690            }
691        }
692    }
693}
694
695fn check_circular_derivation(bundle: &Bundle, report: &mut Report) {
696    let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
697
698    for concept in bundle.concepts() {
699        if warned.contains(&concept.id) {
700            continue;
701        }
702        let mut path = Vec::new();
703        let mut visited = BTreeSet::new();
704
705        if find_derivation_cycle(bundle, &concept.id, &mut path, &mut visited) {
706            for id in &path {
707                warned.insert(id.clone());
708            }
709            let cycle_str: Vec<String> = path.iter().map(ToString::to_string).collect();
710            report.diagnostics.push(Diagnostic {
711                severity: Severity::Warning,
712                path: Some(concept.path.clone()),
713                concept: Some(concept.id.clone()),
714                message: format!(
715                    "[L22] circular concept derivation: {}",
716                    cycle_str.join(" ~> ")
717                ),
718                fixable: false,
719            });
720        }
721    }
722}
723
724fn find_derivation_cycle(
725    bundle: &Bundle,
726    current: &ConceptId,
727    path: &mut Vec<ConceptId>,
728    visited: &mut BTreeSet<ConceptId>,
729) -> bool {
730    path.push(current.clone());
731    visited.insert(current.clone());
732
733    for next in bundle.derived_from(current) {
734        if path.first() == Some(next) || path.contains(next) {
735            path.push((*next).clone());
736            return true;
737        }
738        if !visited.contains(next) && find_derivation_cycle(bundle, next, path, visited) {
739            return true;
740        }
741    }
742
743    path.pop();
744    false
745}
746
747fn check_non_standard_actor(cx: &mut Cx, fm: &Frontmatter) {
748    if let Some(generated) = fm.generated()
749        && let Some(by) = &generated.by
750        && matches!(by.kind(), okf_core::ActorKind::Other)
751    {
752        cx.info(
753            "L23",
754            format!(
755                "actor `{by}` in `generated.by` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
756            ),
757        );
758    }
759    for verification in fm.verified() {
760        if let Some(by) = &verification.by
761            && matches!(by.kind(), okf_core::ActorKind::Other)
762        {
763            cx.info(
764                "L23",
765                format!(
766                    "actor `{by}` in `verified.by` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
767                ),
768            );
769        }
770    }
771    for source in fm.sources() {
772        if let Some(author) = &source.author
773            && matches!(author.kind(), okf_core::ActorKind::Other)
774        {
775            cx.info(
776                "L23",
777                format!(
778                    "author `{author}` in `sources.author` does not follow the standard `human:<id>`, `process:<id>`, or `<producer>/<version>` convention"
779                ),
780            );
781        }
782    }
783}
784
785fn check_future_timestamps(cx: &mut Cx, fm: &Frontmatter, today: Option<Date>) {
786    let check_date = today.or_else(Date::today_utc);
787    let Some(check_date) = check_date else {
788        return;
789    };
790    let threshold_seconds = (check_date.days_since_epoch() + 1) * 86_400;
791
792    if let Some(generated) = fm.generated()
793        && let Some(dt) = generated.at.as_ref().and_then(|a| a.datetime)
794        && dt.to_utc_seconds() > threshold_seconds
795    {
796        cx.warn(
797            "L24",
798            format!("`generated.at` timestamp `{dt}` is in the future"),
799        );
800    }
801    for verification in fm.verified() {
802        if let Some(dt) = verification.at.as_ref().and_then(|a| a.datetime)
803            && dt.to_utc_seconds() > threshold_seconds
804        {
805            cx.warn(
806                "L24",
807                format!("`verified.at` timestamp `{dt}` is in the future"),
808            );
809        }
810    }
811    for source in fm.sources() {
812        if let Some(dt) = source.last_modified.as_ref().and_then(|a| a.datetime)
813            && dt.to_utc_seconds() > threshold_seconds
814        {
815            cx.warn(
816                "L24",
817                format!("`sources.last_modified` timestamp `{dt}` is in the future"),
818            );
819        }
820    }
821}
822
823fn check_attestation_resources(cx: &mut Cx, bundle: &Bundle, doc: &Document) {
824    let Some(contract) = doc.attested_computation() else {
825        return;
826    };
827    if let Some(executor) = &contract.executor
828        && let Some(res) = &executor.resource
829        && !res.starts_with("http://")
830        && !res.starts_with("https://")
831        && bundle.resolve_path_field(&cx.id, res).is_none()
832    {
833        cx.warn(
834            "L25",
835            format!("`executor.resource` points to `{res}` which does not exist on disk"),
836        );
837    }
838    if let Some(attester) = &contract.attester
839        && let Some(res) = &attester.resource
840        && !res.starts_with("http://")
841        && !res.starts_with("https://")
842        && bundle.resolve_path_field(&cx.id, res).is_none()
843    {
844        cx.warn(
845            "L25",
846            format!("`attester.resource` points to `{res}` which does not exist on disk"),
847        );
848    }
849    if let okf_core::computation::ComputationSource::File(path) = &contract.computation
850        && !path.starts_with("http://")
851        && !path.starts_with("https://")
852        && bundle.resolve_path_field(&cx.id, path).is_none()
853    {
854        cx.warn(
855            "L25",
856            format!("`computation` file `{path}` does not exist on disk"),
857        );
858    }
859}
860
861fn check_computation_block_formatting(cx: &mut Cx, doc: &Document) {
862    let Some(contract) = doc.attested_computation() else {
863        return;
864    };
865    if let okf_core::computation::ComputationSource::Inline(inline) = &contract.computation
866        && inline.language.is_none()
867    {
868        cx.warn(
869            "L26",
870            "`# Computation` code block is missing a syntax language tag (e.g. ` ```python ` or ` ```sql `)",
871        );
872    }
873}
874
875fn check_duplicate_log_dates(bundle: &Bundle, report: &mut Report) {
876    for log_path in bundle.log_files() {
877        let Ok(text) = fs::read_to_string(log_path) else {
878            continue;
879        };
880        let log = okf_core::log::Log::parse(&text);
881        let mut seen = HashMap::new();
882        for day in &log.days {
883            *seen.entry(day.date.clone()).or_insert(0) += 1;
884        }
885        for (date, count) in seen {
886            if count > 1 {
887                report.diagnostics.push(Diagnostic {
888                    severity: Severity::Warning,
889                    path: Some(log_path.clone()),
890                    concept: None,
891                    message: format!(
892                        "[L27] log.md contains duplicate date heading `## {date}` (entries should be grouped under a single heading)"
893                    ),
894                    fixable: true,
895                });
896            }
897        }
898    }
899}
900
901fn check_whitespace(cx: &mut Cx, doc: &Document) {
902    let mut trailing_count = 0;
903    let mut first_trailing_line = 0;
904    let mut excess_blank = false;
905    let mut consecutive_blank = 0;
906
907    for (i, line) in doc.body.lines().enumerate() {
908        if line.ends_with(char::is_whitespace) {
909            trailing_count += 1;
910            if first_trailing_line == 0 {
911                first_trailing_line = i + 1;
912            }
913        }
914        if line.trim().is_empty() {
915            consecutive_blank += 1;
916            if consecutive_blank > 2 {
917                excess_blank = true;
918            }
919        } else {
920            consecutive_blank = 0;
921        }
922    }
923
924    let body_trimmed = doc.body.trim_end_matches(['\n', '\r']);
925    let has_trailing_blank_lines = doc.body.len() > body_trimmed.len() + 1
926        && doc.body[body_trimmed.len()..]
927            .chars()
928            .filter(|&c| c == '\n')
929            .count()
930            > 2;
931
932    if trailing_count > 0 {
933        cx.info(
934            "L28",
935            format!(
936                "trailing whitespace found on {trailing_count} line(s) in markdown body (first at line {first_trailing_line})"
937            ),
938        );
939    } else if excess_blank || has_trailing_blank_lines {
940        cx.info(
941            "L28",
942            "excess consecutive blank lines found in markdown body",
943        );
944    }
945}