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            cx.info(format!(
551                "link `{}` names `{}`, which the bundle does not contain; \
552                 §6 tells a consumer to tolerate this",
553                link.raw, link.target
554            ));
555            continue;
556        }
557        if let Some(target) = bundle.get(&link.target)
558            && target.status().is_deprecated()
559        {
560            deprecated.insert(link.target.to_string());
561        }
562    }
563    for target in deprecated {
564        cx.warn(format!("links to deprecated concept `{target}`"));
565    }
566}
567
568/// §3.1: the reserved filenames a concept document may not take.
569///
570/// One of the few plain `MUST NOT`s in the specification, so one of the few
571/// errors here.
572fn check_reserved_filename(cx: &mut Cx<'_>, concept: &Concept) {
573    let name = concept
574        .path
575        .file_name()
576        .and_then(|n| n.to_str())
577        .unwrap_or_default();
578    if okf_core::RESERVED_FILENAMES.contains(&name) {
579        cx.err(format!(
580            "`{name}` is a reserved filename and §3.1 forbids using it for a concept document"
581        ));
582    }
583}
584
585/// §12: a declared OKF version this reader does not implement.
586///
587/// **Absence is not reported**, and that is the specification's decision rather
588/// than leniency: §8 and §12 both say a bundle-root `index.md` *MAY* carry
589/// `okf_version`. An earlier draft of this module warned when it was missing,
590/// and that warning fired on **all four** bundles published with the
591/// specification — the same shape as any check that disagrees with an entire
592/// corpus, and the same conclusion.
593///
594/// A version we do not implement is `info` rather than a warning because §12
595/// tells a consumer that does not understand the declared version to "attempt
596/// best-effort consumption rather than refusing the bundle" — which is what
597/// this reader does, so the note is for the reader and not against the bundle.
598fn check_declared_version(cx: &mut Cx<'_>, bundle: &Bundle) {
599    if let Some(version) = bundle.okf_version()
600        && version != super::OKF_VERSION
601    {
602        cx.at_file(&bundle.root().join("index.md"));
603        cx.info(format!(
604            "the bundle declares `okf_version: {version}`; this reader implements {}, \
605             so it is read best-effort (§12)",
606            super::OKF_VERSION
607        ));
608        cx.concept = None;
609        cx.path = None;
610    }
611}
612
613/// Two concepts with the same title are indistinguishable in any listing.
614fn check_duplicate_titles(cx: &mut Cx<'_>, bundle: &Bundle) {
615    let mut by_title: BTreeMap<String, Vec<&Concept>> = BTreeMap::new();
616    for concept in bundle.concepts() {
617        by_title
618            .entry(concept.display_title())
619            .or_default()
620            .push(concept);
621    }
622    for (title, concepts) in by_title {
623        if concepts.len() < 2 {
624            continue;
625        }
626        let others: Vec<String> = concepts.iter().map(|c| c.id.to_string()).collect();
627        for concept in &concepts {
628            cx.at(concept);
629            // Formatted once per concept rather than once per comparison.
630            let self_id = concept.id.to_string();
631            let siblings: Vec<&String> = others.iter().filter(|id| **id != self_id).collect();
632            cx.warn(format!(
633                "title `{title}` is shared with {}; \
634                 the two are indistinguishable in any listing that shows titles",
635                siblings
636                    .iter()
637                    .map(|s| format!("`{s}`"))
638                    .collect::<Vec<_>>()
639                    .join(", ")
640            ));
641        }
642    }
643}
644
645/// A concept derived, through `sources`, from itself.
646///
647/// An **error**, unlike every other provenance finding: a cycle means no reader
648/// can establish where the claim came from, and following it is unbounded.
649fn check_circular_derivation(cx: &mut Cx<'_>, bundle: &Bundle) {
650    // Edges: concept → the concepts its `sources` name.
651    let mut edges: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
652    for concept in bundle.concepts() {
653        let from = concept.id.to_string();
654        for source in concept.sources() {
655            let Some(resource) = &source.resource else {
656                continue;
657            };
658            let Some(rel) = bundle_relative(resource) else {
659                continue;
660            };
661            // A self-edge is kept. A concept whose `sources` names itself is a
662            // cycle of length one — the shortest way to make provenance
663            // unresolvable — and dropping it as "not really an edge" was the one
664            // shape this rule could not see.
665            if let Some(id) = okf_core::links::concept_id_for_path(&rel)
666                && bundle.contains(&id)
667            {
668                edges
669                    .entry(from.clone())
670                    .or_default()
671                    .insert(id.to_string());
672            }
673        }
674    }
675
676    // Depth-first, reporting the cycle's members rather than only its existence:
677    // "there is a cycle" is not actionable, and a reader needs the ring.
678    let mut seen: BTreeSet<String> = BTreeSet::new();
679    // Keyed by the ring's *members*, not by where the walk happened to start.
680    // `a → b → a` and `b → a → b` are one cycle seen from two ends, and keying
681    // on the start reported it once per member — two errors for one ring.
682    let mut reported: BTreeSet<Vec<String>> = BTreeSet::new();
683    for start in edges.keys() {
684        let mut stack = vec![(start.clone(), vec![start.clone()])];
685        while let Some((node, trail)) = stack.pop() {
686            for next in edges.get(&node).into_iter().flatten() {
687                if next == start {
688                    let ring = trail.join(" → ");
689                    let mut members = trail.clone();
690                    members.sort();
691                    members.dedup();
692                    if reported.insert(members) {
693                        if let Some(concept) = bundle
694                            .concepts()
695                            .iter()
696                            .find(|c| c.id.to_string() == *start)
697                        {
698                            cx.at(concept);
699                        }
700                        cx.err(format!(
701                            "circular derivation: {ring} → {start}; \
702                             no reader can establish where this claim came from"
703                        ));
704                    }
705                    continue;
706                }
707                if seen.insert(format!("{start}\u{0}{next}")) {
708                    let mut trail = trail.clone();
709                    trail.push(next.clone());
710                    stack.push((next.clone(), trail));
711                }
712            }
713        }
714    }
715}
716
717/// An `index.md` that lists a concept the bundle no longer contains.
718///
719/// The other direction — a concept no index lists — is hygiene rather than
720/// conformance, and is `L9` below.
721fn check_stale_indexes(cx: &mut Cx<'_>, bundle: &Bundle) {
722    for index in bundle.index_files() {
723        cx.at_file(index);
724        for (target, resolved) in index_listings(bundle, index) {
725            if !resolved.exists() {
726                cx.warn(format!(
727                    "index lists `{target}`, which no longer exists; \
728                     a reader following the listing lands on nothing"
729                ));
730            }
731        }
732    }
733    cx.concept = None;
734    cx.path = None;
735}
736
737// ---------------------------------------------------------------------------
738// Hygiene (L1..L12)
739// ---------------------------------------------------------------------------
740
741/// `L1` (no top-level heading), `L3` (more than one, or a skipped level) and
742/// `L4` (a heading with nothing under it).
743///
744/// One traversal, because all three are questions about the same heading list
745/// and splitting them would walk the body three times to no purpose.
746fn lint_headings(cx: &mut Cx<'_>, doc: &Document) {
747    let headings = okf_core::markdown::extract_headings(&doc.body);
748    if headings.is_empty() {
749        cx.lint(
750            "warning",
751            "L1",
752            "body has no top-level `#` heading; OKF docs conventionally open with one",
753        );
754        return;
755    }
756
757    let mut top = 0usize;
758    let mut previous = 0usize;
759    for (i, heading) in headings.iter().enumerate() {
760        if heading.level == 1 {
761            top += 1;
762            if top > 1 {
763                cx.lint(
764                    "warning",
765                    "L3",
766                    format!(
767                        "multiple top-level `#` headings found (heading `{}` at line {})",
768                        heading.text, heading.line_num
769                    ),
770                );
771            }
772        }
773        if previous > 0 && heading.level > previous + 1 {
774            cx.lint(
775                "warning",
776                "L3",
777                format!(
778                    "heading level skipped: `{}` jumps from h{previous} to h{}",
779                    heading.text, heading.level
780                ),
781            );
782        }
783        previous = heading.level;
784
785        // Empty when nothing but blank lines follows, *and* nothing is nested
786        // beneath. A heading whose next sibling is deeper is a container — the
787        // content is under its subheadings, not missing. `# Common query
788        // patterns` in the specification's `ga4` bundle is exactly that shape,
789        // and an earlier draft of this rule flagged three of them.
790        let starts = heading.line_index + 1;
791        let ends = headings
792            .get(i + 1)
793            .map_or_else(|| doc.body.lines().count(), |h| h.line_index);
794        let contains_a_deeper_heading = headings
795            .get(i + 1)
796            .is_some_and(|next| next.level > heading.level);
797        let empty = !contains_a_deeper_heading
798            && doc
799                .body
800                .lines()
801                .skip(starts)
802                .take(ends.saturating_sub(starts))
803                .all(|l| l.trim().is_empty());
804        if empty {
805            cx.lint(
806                "warning",
807                "L4",
808                format!("heading `{}` has no content", heading.text),
809            );
810        }
811    }
812
813    if top == 0 {
814        cx.lint(
815            "warning",
816            "L1",
817            "body has no top-level `#` heading; OKF docs conventionally open with one",
818        );
819    }
820}
821
822/// `L2`: frontmatter keys in the canonical order.
823///
824/// Only the keys §5 names are ordered. A producer's own keys are theirs and are
825/// skipped, so a bundle is not nagged for carrying extra metadata.
826fn lint_key_order(cx: &mut Cx<'_>, fm: &Frontmatter) {
827    let rank: BTreeMap<&str, usize> = PREFERRED_KEY_ORDER
828        .iter()
829        .enumerate()
830        .map(|(i, k)| (*k, i))
831        .collect();
832    let ranked: Vec<usize> = fm.keys().filter_map(|k| rank.get(k).copied()).collect();
833    if ranked.windows(2).any(|w| w[0] > w[1]) {
834        cx.lint(
835            "info",
836            "L2",
837            "frontmatter keys are not in canonical order (§5's reading order)",
838        );
839    }
840}
841
842/// `L5`: a declared source nobody cites.
843fn lint_unused_sources(cx: &mut Cx<'_>, concept: &Concept, doc: &Document) {
844    let cited: BTreeSet<String> = doc
845        .footnote_refs()
846        .into_iter()
847        .map(|r| r.label)
848        .chain(doc.footnote_definitions().into_iter().map(|d| d.label))
849        .collect();
850    for source in concept.sources() {
851        let Some(id) = &source.id else { continue };
852        if !cited.contains(id) {
853            cx.lint(
854                "warning",
855                "L5",
856                format!(
857                    "source `{id}` is declared in frontmatter but never cited with footnote `[^{id}]`"
858                ),
859            );
860        }
861    }
862}
863
864/// `L6`: §7's actor convention on a source's author.
865fn lint_actor_convention(cx: &mut Cx<'_>, concept: &Concept) {
866    for source in concept.sources() {
867        let Some(author) = &source.author else {
868            continue;
869        };
870        if author.kind() == okf_core::ActorKind::Other {
871            cx.lint(
872                "info",
873                "L6",
874                format!(
875                    "author `{}` in `sources.author` does not follow §7's `human:<id>`, \
876                     `process:<id>` or `<producer>/<version>` convention",
877                    author.as_str()
878                ),
879            );
880        }
881    }
882}
883
884/// `L7`: a `# Computation` block with no language tag.
885///
886/// Reported, not acted on. `roteiro okf syntax` is what would check the code,
887/// and an untagged block is exactly the one it must skip — so this rule is the
888/// reason a reader ever sees "skipped" there.
889fn lint_computation_block(cx: &mut Cx<'_>, doc: &Document) {
890    if let Some(inline) = doc.inline_computation()
891        && inline.fenced
892        && inline.language.is_none()
893    {
894        cx.lint(
895            "warning",
896            "L7",
897            "`# Computation` code block carries no language tag \
898             (e.g. ```sql), so no syntax check can read it",
899        );
900    }
901}
902
903/// `L8`: trailing whitespace in the body.
904fn lint_whitespace(cx: &mut Cx<'_>, doc: &Document) {
905    let offending: Vec<usize> = doc
906        .body
907        .lines()
908        .enumerate()
909        .filter(|(_, l)| !l.is_empty() && l.trim_end() != *l)
910        .map(|(i, _)| i + 1)
911        .collect();
912    if let Some(first) = offending.first() {
913        cx.lint(
914            "info",
915            "L8",
916            format!(
917                "trailing whitespace found on {} line(s) in markdown body (first at line {first})",
918                offending.len()
919            ),
920        );
921    }
922}
923
924/// `L9`: a concept no index lists.
925fn lint_orphan(cx: &mut Cx<'_>, concept: &Concept, indexed: &BTreeSet<String>) {
926    if !indexed.contains(&concept.id.to_string()) {
927        cx.lint(
928            "warning",
929            "L9",
930            "no `index.md` lists this concept, so nothing walking the bundle's \
931             listings will reach it",
932        );
933    }
934}
935
936/// `R1`: a concept id that will not survive every checkout.
937///
938/// **`R` and not `L`, deliberately.** `L1`..`L12` are upstream's hygiene rules
939/// and that namespace is theirs; this one is Roteiro's, and the specification
940/// states no portability requirement for path segments — §6 constrains what a
941/// path *means*, not what characters it may contain. Numbering it `L13` would
942/// both claim their vocabulary and imply a conformance basis it does not have.
943fn lint_portable_id(cx: &mut Cx<'_>, concept: &Concept) {
944    for segment in concept.id.segments() {
945        if !okf_core::concept_id::is_portable_segment(segment) {
946            cx.lint(
947                "warning",
948                "R1",
949                format!(
950                    "concept-id segment `{segment}` may not survive a checkout on every \
951                     filesystem; the specification does not forbid it, but a consumer on \
952                     a case-insensitive or restricted filesystem cannot read the bundle"
953                ),
954            );
955        }
956    }
957}
958
959/// `L10`: a concept that links to itself.
960fn lint_self_link(cx: &mut Cx<'_>, bundle: &Bundle, concept: &Concept) {
961    if bundle
962        .links_from(&concept.id)
963        .iter()
964        .any(|l| l.target == concept.id)
965    {
966        cx.lint(
967            "warning",
968            "L10",
969            "self-link; a concept that links to itself usually signals a stray reference",
970        );
971    }
972}
973
974/// `L11`: nothing has confirmed this concept.
975fn lint_unverified(cx: &mut Cx<'_>, concept: &Concept) {
976    if concept.trust_tier() == TrustTier::Unverified {
977        cx.lint(
978            "info",
979            "L11",
980            "no `verified` events; trust tier is `unverified`",
981        );
982    }
983}
984
985/// `L12`: a draft concept.
986fn lint_draft(cx: &mut Cx<'_>, concept: &Concept) {
987    if concept.status() == Status::Draft {
988        cx.lint(
989            "warning",
990            "L12",
991            "`status: draft`; a draft concept is not ready for production consumption",
992        );
993    }
994}
995
996// ---------------------------------------------------------------------------
997// Shared helpers
998// ---------------------------------------------------------------------------
999
1000/// Every concept id any `index.md` links to.
1001fn indexed_concepts(bundle: &Bundle) -> BTreeSet<String> {
1002    let mut listed = BTreeSet::new();
1003    for index in bundle.index_files() {
1004        for (_, resolved) in index_listings(bundle, index) {
1005            if let Ok(id) = okf_core::ConceptId::from_path(bundle.root(), &resolved) {
1006                listed.insert(id.to_string());
1007            }
1008        }
1009    }
1010    listed
1011}
1012
1013/// Every concept document one `index.md` links to, as `(as written, resolved)`.
1014///
1015/// Shared by the two rules that read a listing — "this index names something
1016/// gone" and "no index names this concept". They are opposite directions of one
1017/// question, and two copies of the walk would be two chances to resolve a link
1018/// differently and report a contradiction.
1019fn index_listings(bundle: &Bundle, index: &Path) -> Vec<(String, std::path::PathBuf)> {
1020    let Ok(text) = std::fs::read_to_string(index) else {
1021        return Vec::new();
1022    };
1023    let parent = index.parent().unwrap_or_else(|| bundle.root());
1024    okf_core::links::extract_links(&text)
1025        .into_iter()
1026        .filter_map(|link| {
1027            let target = link.target_without_anchor().to_owned();
1028            if target.contains("://")
1029                || !Path::new(&target)
1030                    .extension()
1031                    .is_some_and(|e| e.eq_ignore_ascii_case("md"))
1032            {
1033                return None;
1034            }
1035            let resolved = target
1036                .strip_prefix('/')
1037                .map_or_else(|| parent.join(&target), |rooted| bundle.root().join(rooted));
1038            Some((target, resolved))
1039        })
1040        .collect()
1041}
1042
1043/// The bundle-relative path a `resource:` names, or `None` when it names
1044/// something outside the bundle — a URL, or a path that climbs out of it.
1045fn bundle_relative(raw: &str) -> Option<String> {
1046    if raw.contains("://") || raw.starts_with("mailto:") {
1047        return None;
1048    }
1049    let trimmed = raw.trim_start_matches('/');
1050    if trimmed.is_empty() {
1051        return None;
1052    }
1053
1054    // Nothing that could climb out of the bundle or re-root the join, on
1055    // **either** platform's rules. A bundle is a portable artefact — one written
1056    // on Windows is read on Unix — so the separator cannot be left to whichever
1057    // machine happens to be reading. `..\..` is a single ordinary filename to
1058    // Unix and a climb to Windows, and `C:\…` re-roots the join outright; the
1059    // caller does `bundle.root().join(rel)`, so either would have this checker
1060    // stat a file the bundle does not own.
1061    if trimmed
1062        .split(['/', '\\'])
1063        .any(|segment| segment == ".." || segment == "." || segment.is_empty())
1064    {
1065        return None;
1066    }
1067    // A drive or UNC prefix. A URL was already excluded above, and no portable
1068    // filename carries a colon, so this costs nothing that was readable anyway.
1069    if trimmed.contains(':') {
1070        return None;
1071    }
1072    // The platform's own reading, as a backstop: whatever the two rules above
1073    // missed, every component must still be an ordinary name.
1074    if Path::new(trimmed)
1075        .components()
1076        .any(|c| !matches!(c, std::path::Component::Normal(_)))
1077    {
1078        return None;
1079    }
1080    Some(trimmed.to_owned())
1081}
1082
1083fn as_str(value: &Value) -> Option<&str> {
1084    match value {
1085        Value::String(s) => Some(s.as_str()),
1086        _ => None,
1087    }
1088}
1089
1090/// A YAML value's shape, for a diagnostic that says what was found rather than
1091/// only what was wanted.
1092const fn kind_of(value: &Value) -> &'static str {
1093    match value {
1094        Value::Null => "null",
1095        Value::Bool(_) => "a boolean",
1096        Value::Int(_) => "an integer",
1097        Value::Float(_) => "a number",
1098        Value::String(_) => "a string",
1099        Value::Sequence(_) => "a list",
1100        Value::Mapping(_) => "a mapping",
1101    }
1102}