Skip to main content

rto_render/okf/
conform.rs

1//! Conformance and hygiene checking for an OKF bundle, over `okf-core`'s model.
2//!
3//! # Why this is written here rather than depended on
4//!
5//! Upstream's `okf-validator` does this job and was adopted for a while. It could
6//! not be kept: none of its dependencies is optional and it syntax-checks fenced
7//! code blocks in four languages, so taking it means taking `rustpython-parser` —
8//! 61 crates, `LGPL-3.0-only` through the `malachite` tree, and six unmaintained
9//! advisories whose own text says no safe upgrade exists. `cargo deny` refuses
10//! that on both counts and ADR-0017 §3 forbids admitting a licence merely to turn
11//! CI green.
12//!
13//! What is rebuilt here is the **structural** half, which is the half that is
14//! about OKF. It costs no dependency: every rule below is expressed over
15//! [`okf_core`]'s own model, which is already in the tree.
16//!
17//! # This is not a re-derivation of the specification
18//!
19//! `docs/OKF_BUNDLE.md` warns that re-deriving the format is how two readers of
20//! one spec end up disagreeing, and that warning is the reason `okf-core` was
21//! adopted in the first place. So nothing here parses OKF. Frontmatter, trust
22//! tiers, actor classes, links, footnotes, headings, computations and concept ids
23//! all come from `okf-core`; these functions only ask questions **about** the
24//! model it returns. When the specification changes, the parsing follows upstream
25//! and only the questions are ours.
26//!
27//! # Code syntax is deliberately not checked here
28//!
29//! Whether a fenced `sql` block is valid SQL says nothing about whether a bundle
30//! is valid OKF — a document full of pseudocode is perfectly conformant. Folding
31//! the two together is what made upstream's validator expensive, and it is also
32//! what makes it noisy: run over the four bundles published with the
33//! specification, its SQL arm reports six warnings, every one a documentation
34//! *fragment* in `stackoverflow/references/` that was never meant to be a
35//! statement.
36//!
37//! That question has its own command, [`super::inspect::syntax_report`]
38//! (`roteiro okf syntax`), and its own crate. The two checks below that upstream
39//! spends a Python parser on — `check_code_block_syntax` and
40//! `check_computation_script_syntax` — are therefore **absent by design**, and
41//! their absence is the only intended behavioural difference from upstream over
42//! the published corpus.
43//!
44//! # Determinism
45//!
46//! `stale_after` is checked for **syntax** and never against the clock, so a
47//! bundle that validates today validates tomorrow. A check whose result depends
48//! on when it ran cannot be a gate, and this one is used as one.
49
50use std::collections::{BTreeMap, BTreeSet};
51use std::path::Path;
52
53use okf_core::{
54    Bundle, Concept, Document, Frontmatter, PREFERRED_KEY_ORDER, Status, TrustTier, Value,
55};
56use serde::Serialize;
57
58use super::inspect::InspectError;
59
60/// One finding from [`validate_report`] or [`lint_report`].
61#[derive(Debug, Clone, Serialize)]
62pub struct Finding {
63    /// `error`, `warning` or `info`.
64    pub severity: &'static str,
65    /// The hygiene rule that produced this (`L1`..`L12`), when it was one.
66    ///
67    /// Conformance findings carry no code: they are the specification's rules
68    /// rather than this project's opinions, and numbering them here would invent
69    /// an identifier scheme OKF does not have.
70    pub code: Option<&'static str>,
71    /// The concept the finding is about, if it is about one.
72    pub concept: Option<String>,
73    /// The file the finding is about, relative to the bundle root.
74    pub path: Option<String>,
75    /// What is wrong.
76    pub message: String,
77}
78
79/// The findings from one check over a bundle.
80#[derive(Debug, Clone, Serialize)]
81pub struct CheckReport {
82    /// The bundle root, as the caller named it.
83    pub root: String,
84    /// Which check produced this: `validate` or `lint`.
85    pub check: &'static str,
86    /// How many concepts were examined. Reported so that "no findings" over an
87    /// empty bundle cannot be read as a clean bill of health.
88    pub concepts: usize,
89    /// Findings: errors first, then warnings, then info. Within one severity,
90    /// bundle order is preserved.
91    pub findings: Vec<Finding>,
92    /// Count of `error` findings. Non-zero means the check failed.
93    pub errors: usize,
94    /// Count of `warning` findings.
95    pub warnings: usize,
96}
97
98impl CheckReport {
99    /// `true` when nothing rose to `error`.
100    ///
101    /// Warnings deliberately do not fail: §11 tells a consumer not to reject a
102    /// document over a soft-guidance deviation, and a check that failed on one
103    /// would be unusable against real third-party bundles. Measured: of the 200
104    /// diagnostics upstream reports over the four published bundles, **none** is
105    /// an error — so a gate that failed on warnings would reject the
106    /// specification's own corpus.
107    #[must_use]
108    pub const fn passed(&self) -> bool {
109        self.errors == 0
110    }
111}
112
113/// Accumulates findings, remembering which concept is being examined so each
114/// rule can emit a diagnostic without restating the path and id.
115struct Cx<'a> {
116    findings: Vec<Finding>,
117    concept: Option<String>,
118    path: Option<String>,
119    root: &'a Path,
120}
121
122impl<'a> Cx<'a> {
123    fn new(root: &'a Path) -> Self {
124        Self {
125            findings: Vec::new(),
126            concept: None,
127            path: None,
128            root,
129        }
130    }
131
132    /// Point subsequent findings at `concept`.
133    fn at(&mut self, concept: &Concept) {
134        self.concept = Some(concept.id.to_string());
135        self.path = Some(self.relative(&concept.path));
136    }
137
138    /// Point subsequent findings at a file that is not a concept — an index, a
139    /// log, or a document that failed to parse.
140    fn at_file(&mut self, path: &Path) {
141        self.concept = None;
142        self.path = Some(self.relative(path));
143    }
144
145    /// Bundle-relative, so a report does not leak the absolute path it was run
146    /// from and two runs of the same bundle compare equal.
147    fn relative(&self, path: &Path) -> String {
148        path.strip_prefix(self.root)
149            .unwrap_or(path)
150            .display()
151            .to_string()
152    }
153
154    fn push(&mut self, severity: &'static str, code: Option<&'static str>, message: String) {
155        self.findings.push(Finding {
156            severity,
157            code,
158            concept: self.concept.clone(),
159            path: self.path.clone(),
160            message,
161        });
162    }
163
164    fn err(&mut self, message: impl Into<String>) {
165        self.push("error", None, message.into());
166    }
167
168    fn warn(&mut self, message: impl Into<String>) {
169        self.push("warning", None, message.into());
170    }
171
172    fn info(&mut self, message: impl Into<String>) {
173        self.push("info", None, message.into());
174    }
175
176    /// A hygiene finding, which always carries its rule code.
177    fn lint(&mut self, severity: &'static str, code: &'static str, message: impl Into<String>) {
178        self.push(severity, Some(code), message.into());
179    }
180
181    fn finish(self, root: &Path, check: &'static str, concepts: usize) -> CheckReport {
182        let mut findings = self.findings;
183        // Sorted rather than relied upon: the traversal order is an
184        // implementation detail, while this ordering is what a reader sees first
185        // and what a CI log diff compares. `sort_by_key` is stable, so within a
186        // severity the bundle's own order survives.
187        findings.sort_by_key(|f| match f.severity {
188            "error" => 0u8,
189            "warning" => 1,
190            _ => 2,
191        });
192        CheckReport {
193            root: root.display().to_string(),
194            check,
195            concepts,
196            errors: findings.iter().filter(|f| f.severity == "error").count(),
197            warnings: findings.iter().filter(|f| f.severity == "warning").count(),
198            findings,
199        }
200    }
201}
202
203/// Check a bundle for conformance with the OKF v0.2 specification.
204///
205/// Deterministic — see the module documentation.
206///
207/// # Errors
208///
209/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
210pub fn validate_report(root: &Path) -> Result<CheckReport, InspectError> {
211    let bundle = super::inspect::load(root)?;
212    Ok(validate_bundle(&bundle, root))
213}
214
215/// The bundle-in-hand half of [`validate_report`], so a caller that has already
216/// loaded a [`Bundle`] pays for the directory walk once.
217#[must_use]
218pub fn validate_bundle(bundle: &Bundle, root: &Path) -> CheckReport {
219    let mut cx = Cx::new(root);
220
221    // A document that did not parse is a conformance error, and it is the only
222    // class here that is: everything else is a judgement about a document we
223    // could read.
224    for (path, error) in bundle.parse_errors() {
225        cx.at_file(path);
226        cx.err(format!("not a readable OKF document: {error}"));
227    }
228
229    for concept in bundle.concepts() {
230        cx.at(concept);
231        let doc = &concept.document;
232        let fm = &doc.frontmatter;
233
234        check_type(&mut cx, concept, fm);
235        check_recommended(&mut cx, doc);
236        check_empty_body(&mut cx, doc);
237        check_tags(&mut cx, fm);
238        check_trust(&mut cx, fm);
239        check_lifecycle(&mut cx, fm);
240        check_usage_window(&mut cx, fm);
241        check_attribution(&mut cx, doc);
242        check_legacy(&mut cx, doc, fm);
243        check_computation(&mut cx, concept);
244        check_resources(&mut cx, bundle, concept);
245        check_link_targets(&mut cx, bundle, concept);
246        check_reserved_filename(&mut cx, concept);
247    }
248
249    check_declared_version(&mut cx, bundle);
250    check_duplicate_titles(&mut cx, bundle);
251    check_circular_derivation(&mut cx, bundle);
252    check_stale_indexes(&mut cx, bundle);
253
254    cx.finish(root, "validate", bundle.concepts().len())
255}
256
257/// Check a bundle against the hygiene rules (`L1`..`L12`).
258///
259/// A different question from [`validate_report`]: conformance asks whether the
260/// bundle *is* OKF, linting asks whether it is *good* OKF. Nothing here is a
261/// conformance failure, which is why every rule reports `warning` or `info` and
262/// this check never gates.
263///
264/// # Errors
265///
266/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
267pub fn lint_report(root: &Path) -> Result<CheckReport, InspectError> {
268    let bundle = super::inspect::load(root)?;
269    Ok(lint_bundle(&bundle, root))
270}
271
272/// The bundle-in-hand half of [`lint_report`].
273#[must_use]
274pub fn lint_bundle(bundle: &Bundle, root: &Path) -> CheckReport {
275    let mut cx = Cx::new(root);
276    let indexed = indexed_concepts(bundle);
277
278    for concept in bundle.concepts() {
279        cx.at(concept);
280        let doc = &concept.document;
281        let fm = &doc.frontmatter;
282
283        lint_headings(&mut cx, doc);
284        lint_key_order(&mut cx, fm);
285        lint_unused_sources(&mut cx, concept, doc);
286        lint_actor_convention(&mut cx, concept);
287        lint_computation_block(&mut cx, doc);
288        lint_whitespace(&mut cx, doc);
289        lint_orphan(&mut cx, concept, &indexed);
290        lint_portable_id(&mut cx, concept);
291        lint_self_link(&mut cx, bundle, concept);
292        lint_unverified(&mut cx, concept);
293        lint_draft(&mut cx, concept);
294    }
295
296    cx.finish(root, "lint", bundle.concepts().len())
297}
298
299// ---------------------------------------------------------------------------
300// Conformance
301// ---------------------------------------------------------------------------
302
303/// §4.1: every concept carries a non-empty `type`.
304fn check_type(cx: &mut Cx<'_>, concept: &Concept, fm: &Frontmatter) {
305    if concept.type_().is_none_or(|t| t.trim().is_empty()) {
306        cx.err("`type` is missing or empty; §4.1 requires one on every concept");
307    }
308    // An unknown *value* is not an error. §11 tells a consumer to read
309    // liberally, and a producer's vocabulary is theirs — this is the line
310    // between "not OKF" and "not our OKF".
311    if let Some(t) = fm.type_()
312        && !t.trim().is_empty()
313        && t.trim() != t
314    {
315        cx.info(format!(
316            "`type` has surrounding whitespace (`{t}`); consumers that compare it literally will not match"
317        ));
318    }
319}
320
321/// §4.1's recommended keys. Always a warning — conformance forbids rejecting a
322/// concept over an optional field, however much a producer wants it filled in.
323fn check_recommended(cx: &mut Cx<'_>, doc: &Document) {
324    for key in doc.missing_recommended() {
325        cx.warn(format!("recommended key `{key}` is missing"));
326    }
327}
328
329fn check_empty_body(cx: &mut Cx<'_>, doc: &Document) {
330    if doc.body.trim().is_empty() {
331        cx.warn("body is empty; a concept should carry at least one line of prose or code");
332    }
333}
334
335/// §4.1: `tags` is a list of short strings.
336///
337/// A bare scalar is the shape Google's `stackoverflow` bundle writes in seven
338/// documents. Roteiro's *reader* accepts it (§11, read liberally); this reports
339/// it, because a producer that wrote one string meant one tag and most consumers
340/// will read none.
341fn check_tags(cx: &mut Cx<'_>, fm: &Frontmatter) {
342    match fm.get("tags") {
343        Some(Value::String(_)) => cx.warn(
344            "`tags` should be a list of short strings, found a string; \
345             a strict consumer reads no tags from it",
346        ),
347        Some(Value::Sequence(items)) => {
348            if let Some(bad) = items.iter().find(|v| !matches!(v, Value::String(_))) {
349                cx.warn(format!(
350                    "`tags` contains a non-string entry ({}); §4.1 asks for short strings",
351                    kind_of(bad)
352                ));
353            }
354        }
355        Some(other) => cx.warn(format!(
356            "`tags` should be a list of short strings, found {}",
357            kind_of(other)
358        )),
359        None => {}
360    }
361}
362
363/// §5.2: the `generated` and `verified` trust events.
364fn check_trust(cx: &mut Cx<'_>, fm: &Frontmatter) {
365    if let Some(generated) = fm.generated() {
366        if generated.by.is_none() {
367            cx.warn("`generated.by` is required within `generated`");
368        }
369        match &generated.at {
370            None => cx.warn("`generated.at` is required within `generated`"),
371            Some(at) if at.datetime.is_none() => cx.warn(format!(
372                "`generated.at` is not an ISO-8601 datetime (`{}`)",
373                at.raw
374            )),
375            Some(_) => {}
376        }
377    }
378
379    // Present-but-empty is the case worth reporting: it asserts that
380    // verification happened and then names nobody.
381    if fm.contains_key("verified") {
382        let events = fm.verified();
383        if events.is_empty() {
384            cx.warn("`verified` is present but contains no `{ by, at }` events");
385        }
386        for (i, event) in events.iter().enumerate() {
387            if event.by.is_none() {
388                cx.warn(format!("`verified[{i}].by` is missing"));
389            }
390            match &event.at {
391                None => cx.warn(format!("`verified[{i}].at` is missing")),
392                Some(at) if at.datetime.is_none() => cx.warn(format!(
393                    "`verified[{i}].at` is not an ISO-8601 datetime (`{}`)",
394                    at.raw
395                )),
396                Some(_) => {}
397            }
398        }
399    }
400}
401
402/// §5.4 lifecycle, and §5.5's `stale_after` — its **syntax**, never its relation to now.
403fn check_lifecycle(cx: &mut Cx<'_>, fm: &Frontmatter) {
404    if let Some(Status::Other(value)) = Some(Status::parse(fm.get("status").and_then(as_str))) {
405        cx.info(format!(
406            "`status: {value}` is outside §5.4's `draft | stable | deprecated`; \
407             consumers must tolerate it, but few will act on it"
408        ));
409    }
410    if let Some(raw) = fm.get("stale_after").and_then(as_str)
411        && okf_core::DateTime::parse(raw).is_none()
412    {
413        cx.warn(format!(
414            "`stale_after` is not an ISO-8601 datetime (`{raw}`), so no consumer can act on it"
415        ));
416    }
417}
418
419/// §5.1: a `usage_window` frames sources, so it needs some to frame.
420fn check_usage_window(cx: &mut Cx<'_>, fm: &Frontmatter) {
421    if fm.usage_window().is_some() && fm.sources().is_empty() {
422        cx.warn("`usage_window` is present without `sources` to frame");
423    }
424}
425
426/// §5.1: a footnote is the join key between body prose and a `sources` entry.
427/// (The footnote syntax itself is §4.2.)
428fn check_attribution(cx: &mut Cx<'_>, doc: &Document) {
429    for attribution in doc.attributions() {
430        if attribution.source.is_none() {
431            cx.warn(format!(
432                "footnote [^{}] matches no `sources[].id`; the label is the join key for attribution",
433                attribution.label
434            ));
435        }
436    }
437}
438
439/// §13.1: the keys and body conventions v0.2 superseded.
440fn check_legacy(cx: &mut Cx<'_>, doc: &Document, fm: &Frontmatter) {
441    if fm.timestamp().is_some() {
442        cx.warn("`timestamp` is superseded by `generated.at` (§13.1)");
443    }
444    if doc.has_legacy_citations() {
445        cx.warn("the body `# Citations` list is superseded by `sources` (§13.1)");
446    }
447}
448
449/// §10: an Attested Computation carries a runnable, checkable contract.
450fn check_computation(cx: &mut Cx<'_>, concept: &Concept) {
451    let Some(computation) = concept.attested_computation() else {
452        return;
453    };
454
455    if computation.runtime.as_deref().is_none_or(str::is_empty) {
456        cx.warn("`runtime` is missing; without it nothing knows how to run the computation");
457    }
458    for (i, parameter) in computation.parameters.iter().enumerate() {
459        if parameter.name.is_none() {
460            cx.warn(format!("`parameters[{i}].name` is missing"));
461        }
462        if parameter.type_.is_none() {
463            cx.warn(format!("`parameters[{i}].type` is missing"));
464        }
465    }
466    match &computation.executor {
467        None => cx.warn("missing `executor`: nothing says how to run the computation"),
468        Some(e) if e.resource.is_none() => {
469            cx.warn("`executor.resource` is missing; it names the run instructions or code");
470        }
471        Some(_) => {}
472    }
473    match &computation.attester {
474        None => cx.warn("missing `attester`: nothing can check a run's receipt"),
475        Some(a) if a.resource.is_none() => {
476            cx.warn("`attester.resource` is missing; it names the deterministic check");
477        }
478        Some(_) => {}
479    }
480    if computation.computation.is_missing() {
481        cx.warn(
482            "no computation: neither a `# Computation` block nor a `computation:` path is present, \
483             so there is nothing for an executor to run or an attester to check",
484        );
485    }
486    if computation.has_redundant_inline {
487        cx.warn(
488            "both a `# Computation` block and a `computation:` path are present; \
489             §10 asks for one or the other, and two copies can disagree",
490        );
491    }
492}
493
494/// Every `resource:` that names something inside the bundle must be there.
495///
496/// A URL is left alone: whether `https://…` resolves is a network question, and
497/// this whole module is offline by construction.
498fn check_resources(cx: &mut Cx<'_>, bundle: &Bundle, concept: &Concept) {
499    let check = |label: &str, raw: &str, cx: &mut Cx<'_>| {
500        if let Some(rel) = bundle_relative(raw)
501            && !bundle.root().join(&rel).exists()
502        {
503            cx.warn(format!(
504                "{label} names `{raw}`, which the bundle does not contain"
505            ));
506        }
507    };
508
509    if let Some(resource) = concept.document.frontmatter.resource() {
510        check("`resource`", &resource, cx);
511    }
512    for source in concept.sources() {
513        if let Some(resource) = &source.resource {
514            check("a `sources` entry", resource, cx);
515        }
516    }
517    if let Some(computation) = concept.attested_computation() {
518        if let Some(e) = computation
519            .executor
520            .as_ref()
521            .and_then(|e| e.resource.clone())
522        {
523            check("`executor.resource`", &e, cx);
524        }
525        if let Some(a) = computation
526            .attester
527            .as_ref()
528            .and_then(|a| a.resource.clone())
529        {
530            check("`attester.resource`", &a, cx);
531        }
532        if let Some(path) = computation.computation.path() {
533            check("`computation`", path, cx);
534        }
535    }
536}
537
538/// Links that resolve, and what they resolve *to*.
539///
540/// A broken cross-link is `info`, not an error: §11 permits it, and a bundle is
541/// often one half of a set. A link to a **deprecated** concept is a warning,
542/// because the target is telling the reader to go somewhere else.
543fn check_link_targets(cx: &mut Cx<'_>, bundle: &Bundle, concept: &Concept) {
544    // Deprecation is reported once per *target*, not once per link. A document
545    // that mentions a retired concept twice has one problem, and `gross-margin`
546    // in the specification's own `acme_retail` does exactly that.
547    let mut deprecated: BTreeSet<String> = BTreeSet::new();
548    for link in bundle.links_from(&concept.id) {
549        if !link.exists {
550            // `exists` means "resolves to a concept", so a link to a diagram or a
551            // data file beside the concept lands here — and saying the bundle
552            // "does not contain" a file it demonstrably does is simply false.
553            // `check_resources` above already settles this the right way for
554            // frontmatter paths, by asking the filesystem; this asks the same
555            // question of link targets. Issue #778, where the three commands
556            // disagreed about the same four links.
557            if bundle.resolve_path_field(&concept.id, &link.raw).is_none() {
558                cx.info(format!(
559                    "link `{}` names `{}`, which the bundle does not contain; \
560                     §6 tells a consumer to tolerate this",
561                    link.raw, link.target
562                ));
563            }
564            continue;
565        }
566        if let Some(target) = bundle.get(&link.target)
567            && target.status().is_deprecated()
568        {
569            deprecated.insert(link.target.to_string());
570        }
571    }
572    for target in deprecated {
573        cx.warn(format!("links to deprecated concept `{target}`"));
574    }
575}
576
577/// §3.1: the reserved filenames a concept document may not take.
578///
579/// One of the few plain `MUST NOT`s in the specification, so one of the few
580/// errors here.
581fn check_reserved_filename(cx: &mut Cx<'_>, concept: &Concept) {
582    let name = concept
583        .path
584        .file_name()
585        .and_then(|n| n.to_str())
586        .unwrap_or_default();
587    if okf_core::RESERVED_FILENAMES.contains(&name) {
588        cx.err(format!(
589            "`{name}` is a reserved filename and §3.1 forbids using it for a concept document"
590        ));
591    }
592}
593
594/// §12: a declared OKF version this reader does not implement.
595///
596/// **Absence is not reported**, and that is the specification's decision rather
597/// than leniency: §8 and §12 both say a bundle-root `index.md` *MAY* carry
598/// `okf_version`. An earlier draft of this module warned when it was missing,
599/// and that warning fired on **all four** bundles published with the
600/// specification — the same shape as any check that disagrees with an entire
601/// corpus, and the same conclusion.
602///
603/// A version we do not implement is `info` rather than a warning because §12
604/// tells a consumer that does not understand the declared version to "attempt
605/// best-effort consumption rather than refusing the bundle" — which is what
606/// this reader does, so the note is for the reader and not against the bundle.
607fn check_declared_version(cx: &mut Cx<'_>, bundle: &Bundle) {
608    if let Some(version) = bundle.okf_version()
609        && version != super::OKF_VERSION
610    {
611        cx.at_file(&bundle.root().join("index.md"));
612        cx.info(format!(
613            "the bundle declares `okf_version: {version}`; this reader implements {}, \
614             so it is read best-effort (§12)",
615            super::OKF_VERSION
616        ));
617        cx.concept = None;
618        cx.path = None;
619    }
620}
621
622/// Two concepts with the same title are indistinguishable in any listing.
623fn check_duplicate_titles(cx: &mut Cx<'_>, bundle: &Bundle) {
624    let mut by_title: BTreeMap<String, Vec<&Concept>> = BTreeMap::new();
625    for concept in bundle.concepts() {
626        by_title
627            .entry(concept.display_title())
628            .or_default()
629            .push(concept);
630    }
631    for (title, concepts) in by_title {
632        if concepts.len() < 2 {
633            continue;
634        }
635        let others: Vec<String> = concepts.iter().map(|c| c.id.to_string()).collect();
636        for concept in &concepts {
637            cx.at(concept);
638            // Formatted once per concept rather than once per comparison.
639            let self_id = concept.id.to_string();
640            let siblings: Vec<&String> = others.iter().filter(|id| **id != self_id).collect();
641            cx.warn(format!(
642                "title `{title}` is shared with {}; \
643                 the two are indistinguishable in any listing that shows titles",
644                siblings
645                    .iter()
646                    .map(|s| format!("`{s}`"))
647                    .collect::<Vec<_>>()
648                    .join(", ")
649            ));
650        }
651    }
652}
653
654/// A concept derived, through `sources`, from itself.
655///
656/// An **error**, unlike every other provenance finding: a cycle means no reader
657/// can establish where the claim came from, and following it is unbounded.
658fn check_circular_derivation(cx: &mut Cx<'_>, bundle: &Bundle) {
659    // Edges: concept → the concepts its `sources` name.
660    let mut edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
661    for concept in bundle.concepts() {
662        let from = concept.id.to_string();
663        for source in concept.sources() {
664            let Some(resource) = &source.resource else {
665                continue;
666            };
667            let Some(rel) = bundle_relative(resource) else {
668                continue;
669            };
670            // A self-edge is kept. A concept whose `sources` names itself is a
671            // cycle of length one — the shortest way to make provenance
672            // unresolvable — and dropping it as "not really an edge" was the one
673            // shape this rule could not see.
674            if let Some(id) = okf_core::links::concept_id_for_path(&rel)
675                && bundle.contains(&id)
676            {
677                edges
678                    .entry(from.clone())
679                    .or_default()
680                    .insert(id.to_string());
681            }
682        }
683    }
684
685    // Depth-first, reporting the cycle's members rather than only its existence:
686    // "there is a cycle" is not actionable, and a reader needs the ring.
687    let mut seen: BTreeSet<String> = BTreeSet::new();
688    // Keyed by the ring's *members*, not by where the walk happened to start.
689    // `a → b → a` and `b → a → b` are one cycle seen from two ends, and keying
690    // on the start reported it once per member — two errors for one ring.
691    let mut reported: BTreeSet<Vec<String>> = BTreeSet::new();
692    for start in edges.keys() {
693        let mut stack = vec![(start.clone(), vec![start.clone()])];
694        while let Some((node, trail)) = stack.pop() {
695            for next in edges.get(&node).into_iter().flatten() {
696                if next == start {
697                    let ring = trail.join(" → ");
698                    let mut members = trail.clone();
699                    members.sort();
700                    members.dedup();
701                    if reported.insert(members) {
702                        if let Some(concept) = bundle
703                            .concepts()
704                            .iter()
705                            .find(|c| c.id.to_string() == *start)
706                        {
707                            cx.at(concept);
708                        }
709                        cx.err(format!(
710                            "circular derivation: {ring} → {start}; \
711                             no reader can establish where this claim came from"
712                        ));
713                    }
714                    continue;
715                }
716                if seen.insert(format!("{start}\u{0}{next}")) {
717                    let mut trail = trail.clone();
718                    trail.push(next.clone());
719                    stack.push((next.clone(), trail));
720                }
721            }
722        }
723    }
724}
725
726/// An `index.md` that lists a concept the bundle no longer contains.
727///
728/// The other direction — a concept no index lists — is hygiene rather than
729/// conformance, and is `L9` below.
730fn check_stale_indexes(cx: &mut Cx<'_>, bundle: &Bundle) {
731    for index in bundle.index_files() {
732        cx.at_file(index);
733        for (target, resolved) in index_listings(bundle, index) {
734            if !resolved.exists() {
735                cx.warn(format!(
736                    "index lists `{target}`, which no longer exists; \
737                     a reader following the listing lands on nothing"
738                ));
739            }
740        }
741    }
742    cx.concept = None;
743    cx.path = None;
744}
745
746// ---------------------------------------------------------------------------
747// Hygiene (L1..L12)
748// ---------------------------------------------------------------------------
749
750/// `L1` (no top-level heading), `L3` (more than one, or a skipped level) and
751/// `L4` (a heading with nothing under it).
752///
753/// One traversal, because all three are questions about the same heading list
754/// and splitting them would walk the body three times to no purpose.
755fn lint_headings(cx: &mut Cx<'_>, doc: &Document) {
756    let headings = okf_core::markdown::extract_headings(&doc.body);
757    if headings.is_empty() {
758        cx.lint(
759            "warning",
760            "L1",
761            "body has no top-level `#` heading; OKF docs conventionally open with one",
762        );
763        return;
764    }
765
766    let mut top = 0usize;
767    let mut previous = 0usize;
768    for (i, heading) in headings.iter().enumerate() {
769        if heading.level == 1 {
770            top += 1;
771            if top > 1 {
772                cx.lint(
773                    "warning",
774                    "L3",
775                    format!(
776                        "multiple top-level `#` headings found (heading `{}` at line {})",
777                        heading.text, heading.line_num
778                    ),
779                );
780            }
781        }
782        if previous > 0 && heading.level > previous + 1 {
783            cx.lint(
784                "warning",
785                "L3",
786                format!(
787                    "heading level skipped: `{}` jumps from h{previous} to h{}",
788                    heading.text, heading.level
789                ),
790            );
791        }
792        previous = heading.level;
793
794        // Empty when nothing but blank lines follows, *and* nothing is nested
795        // beneath. A heading whose next sibling is deeper is a container — the
796        // content is under its subheadings, not missing. `# Common query
797        // patterns` in the specification's `ga4` bundle is exactly that shape,
798        // and an earlier draft of this rule flagged three of them.
799        let starts = heading.line_index + 1;
800        let ends = headings
801            .get(i + 1)
802            .map_or_else(|| doc.body.lines().count(), |h| h.line_index);
803        let contains_a_deeper_heading = headings
804            .get(i + 1)
805            .is_some_and(|next| next.level > heading.level);
806        let empty = !contains_a_deeper_heading
807            && doc
808                .body
809                .lines()
810                .skip(starts)
811                .take(ends.saturating_sub(starts))
812                .all(|l| l.trim().is_empty());
813        if empty {
814            cx.lint(
815                "warning",
816                "L4",
817                format!("heading `{}` has no content", heading.text),
818            );
819        }
820    }
821
822    if top == 0 {
823        cx.lint(
824            "warning",
825            "L1",
826            "body has no top-level `#` heading; OKF docs conventionally open with one",
827        );
828    }
829}
830
831/// `L2`: frontmatter keys in the canonical order.
832///
833/// Only the keys §5 names are ordered. A producer's own keys are theirs and are
834/// skipped, so a bundle is not nagged for carrying extra metadata.
835fn lint_key_order(cx: &mut Cx<'_>, fm: &Frontmatter) {
836    let rank: BTreeMap<&str, usize> = PREFERRED_KEY_ORDER
837        .iter()
838        .enumerate()
839        .map(|(i, k)| (*k, i))
840        .collect();
841    let ranked: Vec<usize> = fm.keys().filter_map(|k| rank.get(k).copied()).collect();
842    if ranked.windows(2).any(|w| w[0] > w[1]) {
843        cx.lint(
844            "info",
845            "L2",
846            "frontmatter keys are not in canonical order (§5's reading order)",
847        );
848    }
849}
850
851/// `L5`: a declared source nobody cites.
852fn lint_unused_sources(cx: &mut Cx<'_>, concept: &Concept, doc: &Document) {
853    let cited: BTreeSet<String> = doc
854        .footnote_refs()
855        .into_iter()
856        .map(|r| r.label)
857        .chain(doc.footnote_definitions().into_iter().map(|d| d.label))
858        .collect();
859    for source in concept.sources() {
860        let Some(id) = &source.id else { continue };
861        if !cited.contains(id) {
862            cx.lint(
863                "warning",
864                "L5",
865                format!(
866                    "source `{id}` is declared in frontmatter but never cited with footnote `[^{id}]`"
867                ),
868            );
869        }
870    }
871}
872
873/// `L6`: §7's actor convention on a source's author.
874fn lint_actor_convention(cx: &mut Cx<'_>, concept: &Concept) {
875    for source in concept.sources() {
876        let Some(author) = &source.author else {
877            continue;
878        };
879        if author.kind() == okf_core::ActorKind::Other {
880            cx.lint(
881                "info",
882                "L6",
883                format!(
884                    "author `{}` in `sources.author` does not follow §7's `human:<id>`, \
885                     `process:<id>` or `<producer>/<version>` convention",
886                    author.as_str()
887                ),
888            );
889        }
890    }
891}
892
893/// `L7`: a `# Computation` block with no language tag.
894///
895/// Reported, not acted on. `roteiro okf syntax` is what would check the code,
896/// and an untagged block is exactly the one it must skip — so this rule is the
897/// reason a reader ever sees "skipped" there.
898fn lint_computation_block(cx: &mut Cx<'_>, doc: &Document) {
899    if let Some(inline) = doc.inline_computation()
900        && inline.fenced
901        && inline.language.is_none()
902    {
903        cx.lint(
904            "warning",
905            "L7",
906            "`# Computation` code block carries no language tag \
907             (e.g. ```sql), so no syntax check can read it",
908        );
909    }
910}
911
912/// `L8`: trailing whitespace in the body.
913fn lint_whitespace(cx: &mut Cx<'_>, doc: &Document) {
914    let offending: Vec<usize> = doc
915        .body
916        .lines()
917        .enumerate()
918        .filter(|(_, l)| !l.is_empty() && l.trim_end() != *l)
919        .map(|(i, _)| i + 1)
920        .collect();
921    if let Some(first) = offending.first() {
922        cx.lint(
923            "info",
924            "L8",
925            format!(
926                "trailing whitespace found on {} line(s) in markdown body (first at line {first})",
927                offending.len()
928            ),
929        );
930    }
931}
932
933/// `L9`: a concept no index lists.
934fn lint_orphan(cx: &mut Cx<'_>, concept: &Concept, indexed: &BTreeSet<String>) {
935    if !indexed.contains(&concept.id.to_string()) {
936        cx.lint(
937            "warning",
938            "L9",
939            "no `index.md` lists this concept, so nothing walking the bundle's \
940             listings will reach it",
941        );
942    }
943}
944
945/// `R1`: a concept id that will not survive every checkout.
946///
947/// **`R` and not `L`, deliberately.** `L1`..`L12` are upstream's hygiene rules
948/// and that namespace is theirs; this one is Roteiro's, and the specification
949/// states no portability requirement for path segments — §6 constrains what a
950/// path *means*, not what characters it may contain. Numbering it `L13` would
951/// both claim their vocabulary and imply a conformance basis it does not have.
952fn lint_portable_id(cx: &mut Cx<'_>, concept: &Concept) {
953    for segment in concept.id.segments() {
954        if !okf_core::concept_id::is_portable_segment(segment) {
955            cx.lint(
956                "warning",
957                "R1",
958                format!(
959                    "concept-id segment `{segment}` may not survive a checkout on every \
960                     filesystem; the specification does not forbid it, but a consumer on \
961                     a case-insensitive or restricted filesystem cannot read the bundle"
962                ),
963            );
964        }
965    }
966}
967
968/// `L10`: a concept that links to itself.
969fn lint_self_link(cx: &mut Cx<'_>, bundle: &Bundle, concept: &Concept) {
970    if bundle
971        .links_from(&concept.id)
972        .iter()
973        .any(|l| l.target == concept.id)
974    {
975        cx.lint(
976            "warning",
977            "L10",
978            "self-link; a concept that links to itself usually signals a stray reference",
979        );
980    }
981}
982
983/// `L11`: nothing has confirmed this concept.
984fn lint_unverified(cx: &mut Cx<'_>, concept: &Concept) {
985    if concept.trust_tier() == TrustTier::Unverified {
986        cx.lint(
987            "info",
988            "L11",
989            "no `verified` events; trust tier is `unverified`",
990        );
991    }
992}
993
994/// `L12`: a draft concept.
995fn lint_draft(cx: &mut Cx<'_>, concept: &Concept) {
996    if concept.status() == Status::Draft {
997        cx.lint(
998            "warning",
999            "L12",
1000            "`status: draft`; a draft concept is not ready for production consumption",
1001        );
1002    }
1003}
1004
1005// ---------------------------------------------------------------------------
1006// Shared helpers
1007// ---------------------------------------------------------------------------
1008
1009/// Every concept id any `index.md` links to.
1010fn indexed_concepts(bundle: &Bundle) -> BTreeSet<String> {
1011    let mut listed = BTreeSet::new();
1012    for index in bundle.index_files() {
1013        for (_, resolved) in index_listings(bundle, index) {
1014            if let Ok(id) = okf_core::ConceptId::from_path(bundle.root(), &resolved) {
1015                listed.insert(id.to_string());
1016            }
1017        }
1018    }
1019    listed
1020}
1021
1022/// Every concept document one `index.md` links to, as `(as written, resolved)`.
1023///
1024/// Shared by the two rules that read a listing — "this index names something
1025/// gone" and "no index names this concept". They are opposite directions of one
1026/// question, and two copies of the walk would be two chances to resolve a link
1027/// differently and report a contradiction.
1028fn index_listings(bundle: &Bundle, index: &Path) -> Vec<(String, std::path::PathBuf)> {
1029    let Ok(text) = std::fs::read_to_string(index) else {
1030        return Vec::new();
1031    };
1032    let parent = index.parent().unwrap_or_else(|| bundle.root());
1033    okf_core::links::extract_links(&text)
1034        .into_iter()
1035        .filter_map(|link| {
1036            let target = link.target_without_anchor().to_owned();
1037            if target.contains("://")
1038                || !Path::new(&target)
1039                    .extension()
1040                    .is_some_and(|e| e.eq_ignore_ascii_case("md"))
1041            {
1042                return None;
1043            }
1044            let resolved = target
1045                .strip_prefix('/')
1046                .map_or_else(|| parent.join(&target), |rooted| bundle.root().join(rooted));
1047            Some((target, resolved))
1048        })
1049        .collect()
1050}
1051
1052/// The bundle-relative path a `resource:` names, or `None` when it names
1053/// something outside the bundle — a URL, or a path that climbs out of it.
1054fn bundle_relative(raw: &str) -> Option<String> {
1055    if raw.contains("://") || raw.starts_with("mailto:") {
1056        return None;
1057    }
1058    let trimmed = raw.trim_start_matches('/');
1059    if trimmed.is_empty() {
1060        return None;
1061    }
1062
1063    // Nothing that could climb out of the bundle or re-root the join, on
1064    // **either** platform's rules. A bundle is a portable artefact — one written
1065    // on Windows is read on Unix — so the separator cannot be left to whichever
1066    // machine happens to be reading. `..\..` is a single ordinary filename to
1067    // Unix and a climb to Windows, and `C:\…` re-roots the join outright; the
1068    // caller does `bundle.root().join(rel)`, so either would have this checker
1069    // stat a file the bundle does not own.
1070    if trimmed
1071        .split(['/', '\\'])
1072        .any(|segment| segment == ".." || segment == "." || segment.is_empty())
1073    {
1074        return None;
1075    }
1076    // A drive or UNC prefix. A URL was already excluded above, and no portable
1077    // filename carries a colon, so this costs nothing that was readable anyway.
1078    if trimmed.contains(':') {
1079        return None;
1080    }
1081    // The platform's own reading, as a backstop: whatever the two rules above
1082    // missed, every component must still be an ordinary name.
1083    if Path::new(trimmed)
1084        .components()
1085        .any(|c| !matches!(c, std::path::Component::Normal(_)))
1086    {
1087        return None;
1088    }
1089    Some(trimmed.to_owned())
1090}
1091
1092fn as_str(value: &Value) -> Option<&str> {
1093    match value {
1094        Value::String(s) => Some(s.as_str()),
1095        _ => None,
1096    }
1097}
1098
1099/// A YAML value's shape, for a diagnostic that says what was found rather than
1100/// only what was wanted.
1101const fn kind_of(value: &Value) -> &'static str {
1102    match value {
1103        Value::Null => "null",
1104        Value::Bool(_) => "a boolean",
1105        Value::Int(_) => "an integer",
1106        Value::Float(_) => "a number",
1107        Value::String(_) => "a string",
1108        Value::Sequence(_) => "a list",
1109        Value::Mapping(_) => "a mapping",
1110    }
1111}