Skip to main content

okf_validator/
validate.rs

1//! Conformance checking and structural validation against OKF v0.2.
2//!
3//! A bundle is **conformant** if (1) every non-reserved `.md` file has a
4//! parseable frontmatter block, (2) every frontmatter has a non-empty `type`,
5//! and (3) reserved files follow their structure when present. Everything else
6//! is soft guidance: consumers MUST NOT reject a bundle for missing optional
7//! fields, unknown types or keys, broken links, or missing `index.md` files.
8//!
9//! Accordingly, [`validate_bundle`] reports only true conformance violations as
10//! [`Severity::Error`]. Material soft-guidance deviations, data integrity issues,
11//! temporal inconsistencies, contract discrepancies, and syntax errors are
12//! reported as [`Severity::Warning`] (producer mistakes worth fixing)
13//! or [`Severity::Info`] (permitted states worth noting).
14//!
15//! Staleness is the one check that depends on the wall clock, so it is opt-in:
16//! [`validate_bundle`] is deterministic and [`validate_bundle_at`] takes the
17//! date to compare against.
18//!
19//! | Code | Severity | Finding                                                                            |
20//! |------|----------|------------------------------------------------------------------------------------|
21//! | V1   | error    | unparseable concept document (frontmatter parse error)                             |
22//! | V2   | error    | missing or non-scalar required `type` field                                        |
23//! | V3   | warning  | missing recommended frontmatter field (`title`, `description`, `generated`)        |
24//! | V4   | warning  | concept body is empty                                                              |
25//! | V5   | warning  | `tags` is not a YAML list of strings                                               |
26//! | V6   | warning  | `generated` is malformed, missing `by`, or `at` is not a valid ISO datetime        |
27//! | V7   | warning  | `verified` is malformed, missing `by`, or `at` is not a valid ISO datetime         |
28//! | V8   | warning  | latest `verified.at` predates `generated.at` (content modified after verification) |
29//! | V9   | warning  | timestamp in `generated`, `verified`, or `sources` is in the future                |
30//! | V10  | warning  | unknown `status` value (not `draft`, `stable`, `deprecated`)                       |
31//! | V11  | warning  | `stale_after` is not a valid ISO datetime                                          |
32//! | V12  | info     | stale concept (past `stale_after` with `--today`)                                  |
33//! | V13  | warning  | `sources` malformed, missing `resource`, or duplicate `id`                         |
34//! | V14  | warning  | `sources.last_modified` or `usage_window` not valid ISO datetime                   |
35//! | V15  | warning  | `sources.usage_count` without `usage_window` or not an integer                     |
36//! | V16  | warning  | footnote attribution matches no `sources[].id`                                     |
37//! | V17  | warning  | footnote is cited in body but never defined                                        |
38//! | V18  | warning  | circular concept derivation in sources graph                                       |
39//! | V19  | warning  | legacy v0.1 `timestamp` present (superseded by `generated`)                        |
40//! | V20  | warning  | legacy v0.1 body `# Citations` list present (superseded by `sources`)              |
41//! | V21  | warning  | missing `runtime` or `computation` source on `Attested Computation`                |
42//! | V22  | warning  | contract `parameters`, `executor`, or `attester` missing required fields           |
43//! | V23  | warning  | `executor`, `attester`, or `computation` resource missing on disk                  |
44//! | V24  | warning  | code block syntax error in concept body                                            |
45//! | V25  | warning  | computation, executor, or attester script syntax error                             |
46//! | V26  | info     | computation fields on non-computation concept type                                 |
47//! | V27  | info     | explicit path field does not resolve to a file in the bundle                       |
48//! | V28  | info     | broken link (target does not resolve to a concept in the bundle)                   |
49//! | V29  | warning  | links to a `status: deprecated` concept                                            |
50//! | V30  | warning  | `title` shared with another concept                                                |
51//! | V31  | warning  | concept-id segment outside portable ASCII set                                      |
52//! | V32  | error    | reserved `index.md` or `log.md` unreadable, unparseable, or bad frontmatter        |
53//! | V33  | error    | reserved `log.md` structural errors or invalid ISO date format                     |
54//! | V34  | warning  | `log.md` contains duplicate date heading `## YYYY-MM-DD`                           |
55//! | V35  | warning  | existing `index.md` is out of sync with its directory                              |
56//! | V36  | info     | bundle declares unrecognized or non-target `okf_version`                           |
57
58use okf_core::bundle::Bundle;
59use okf_core::computation::{ATTESTED_COMPUTATION_TYPE, ComputationSource};
60use okf_core::concept_id::ConceptId;
61use okf_core::date::{Date, DateTime};
62use okf_core::document::Document;
63use okf_core::frontmatter::Frontmatter;
64use okf_core::log::Log;
65use okf_core::provenance::{ResourceKind, Source};
66use okf_core::trust::{STATUS_VALUES, Verification};
67use okf_core::yaml::Value;
68use std::collections::{BTreeSet, HashMap, HashSet};
69use std::fmt;
70use std::fs;
71use std::path::{Path, PathBuf};
72
73/// Severity of a diagnostic.
74///
75/// Ordered by increasing severity: `Info < Warning < Error`.
76#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
77pub enum Severity {
78    /// Informational note, for example a permitted but noteworthy cross-link or draft state.
79    Info,
80    /// A soft-guidance deviation (the bundle is still conformant).
81    Warning,
82    /// A conformance violation.
83    Error,
84}
85
86impl Severity {
87    /// The string representation of this severity level (`"info"`, `"warning"`, `"error"`).
88    #[must_use]
89    pub const fn as_str(&self) -> &'static str {
90        match self {
91            Self::Info => "info",
92            Self::Warning => "warning",
93            Self::Error => "error",
94        }
95    }
96}
97
98impl AsRef<str> for Severity {
99    fn as_ref(&self) -> &str {
100        self.as_str()
101    }
102}
103
104/// Error returned when a string cannot be parsed into a [`Severity`].
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct ParseSeverityError(pub String);
107
108impl fmt::Display for ParseSeverityError {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "unknown severity: {:?}", self.0)
111    }
112}
113
114impl std::error::Error for ParseSeverityError {}
115
116impl std::str::FromStr for Severity {
117    type Err = ParseSeverityError;
118    fn from_str(s: &str) -> Result<Self, Self::Err> {
119        match s.trim().to_ascii_lowercase().as_str() {
120            "info" => Ok(Self::Info),
121            "warning" | "warn" => Ok(Self::Warning),
122            "error" => Ok(Self::Error),
123            other => Err(ParseSeverityError(other.to_string())),
124        }
125    }
126}
127
128impl fmt::Display for Severity {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.write_str(self.as_str())
131    }
132}
133
134/// A single finding about a bundle.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct Diagnostic {
137    /// How serious the finding is.
138    pub severity: Severity,
139    /// The file the finding relates to, if any.
140    pub path: Option<PathBuf>,
141    /// The concept the finding relates to, if any.
142    pub concept: Option<ConceptId>,
143    /// A human-readable message.
144    pub message: String,
145    /// Whether this finding can be automatically remediated with `okf fix`.
146    pub fixable: bool,
147}
148
149impl std::fmt::Display for Diagnostic {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        write!(f, "[{}] ", self.severity)?;
152        if let Some(p) = &self.path {
153            write!(f, "{}: ", p.display())?;
154        } else if let Some(c) = &self.concept {
155            write!(f, "{c}: ")?;
156        }
157        f.write_str(&self.message)
158    }
159}
160
161/// The result of validating a bundle.
162#[derive(Clone, Debug, Default)]
163pub struct Report {
164    /// All findings, errors first by construction order.
165    pub diagnostics: Vec<Diagnostic>,
166}
167
168impl Report {
169    /// `true` if there are no [`Severity::Error`] diagnostics, i.e. the bundle
170    /// is conformant.
171    #[must_use]
172    pub fn is_conformant(&self) -> bool {
173        !self
174            .diagnostics
175            .iter()
176            .any(|d| d.severity == Severity::Error)
177    }
178
179    /// Iterates over diagnostics of a given severity.
180    pub fn of(&self, severity: Severity) -> impl Iterator<Item = &Diagnostic> {
181        self.diagnostics
182            .iter()
183            .filter(move |d| d.severity == severity)
184    }
185
186    /// Count of error-level diagnostics.
187    #[must_use]
188    pub fn error_count(&self) -> usize {
189        self.of(Severity::Error).count()
190    }
191
192    /// Count of warning-level diagnostics.
193    #[must_use]
194    pub fn warning_count(&self) -> usize {
195        self.of(Severity::Warning).count()
196    }
197
198    /// Count of findings that can be automatically fixed with `okf fix`.
199    #[must_use]
200    pub fn fixable_count(&self) -> usize {
201        self.diagnostics.iter().filter(|d| d.fixable).count()
202    }
203}
204
205/// Validates a loaded bundle, returning all findings.
206///
207/// Deterministic: `stale_after` dates are checked for *syntax* but not against
208/// the clock. Use [`validate_bundle_at`] to also flag stale concepts.
209#[must_use]
210pub fn validate_bundle(bundle: &Bundle) -> Report {
211    validate_bundle_at(bundle, None)
212}
213
214/// Validates a bundle, additionally reporting concepts that are stale on
215/// `today`.
216#[must_use]
217pub fn validate_bundle_at(bundle: &Bundle, today: Option<Date>) -> Report {
218    let mut report = Report::default();
219
220    // (1) Files whose frontmatter could not be parsed are conformance errors.
221    for (path, error) in bundle.parse_errors() {
222        report.error(
223            Some(path.clone()),
224            None,
225            format!("unparseable concept document: {error}"),
226        );
227    }
228
229    // (2) Every concept must carry a non-empty `type`. Everything else the
230    // families add is soft guidance.
231    for concept in bundle.concepts() {
232        let mut cx = Context {
233            report: &mut report,
234            path: concept.path.clone(),
235            id: concept.id.clone(),
236        };
237        let fm = &concept.document.frontmatter;
238
239        if concept.document.validate().is_err() {
240            if fm
241                .get("type")
242                .is_some_and(|value| value.as_display_str().is_none())
243            {
244                cx.error("`type` must be a non-empty scalar");
245            } else {
246                cx.error("missing required frontmatter field `type`");
247            }
248        }
249        check_recommended(&mut cx, &concept.document);
250        check_empty_body(&mut cx, &concept.document);
251        check_tags(&mut cx, fm);
252        check_trust(&mut cx, fm, today);
253        check_lifecycle(&mut cx, fm, today);
254        check_provenance(&mut cx, fm, today);
255        check_attribution(&mut cx, &concept.document);
256        check_legacy(&mut cx, &concept.document);
257        check_computation(&mut cx, bundle, &concept.document);
258        check_code_block_syntax(&mut cx, &concept.document);
259        check_computation_script_syntax(&mut cx, bundle, &concept.document);
260        check_path_fields(&mut cx, bundle, fm);
261        check_links_to_deprecated(&mut cx, bundle);
262        check_link_anchors(&mut cx, bundle, &concept.document);
263    }
264
265    // (3) Reserved files must follow their structure when present.
266    check_segment_portability(bundle, &mut report);
267    validate_reserved(bundle, &mut report);
268    check_declared_version(bundle, &mut report);
269
270    // Cross-bundle checks:
271    check_duplicate_titles(bundle, &mut report);
272    check_circular_derivation(bundle, &mut report);
273    check_stale_indexes(bundle, &mut report);
274
275    // Broken cross-links are permitted by the spec; report them as info.
276    for (source, raw) in bundle.broken_links() {
277        report.info(
278            None,
279            Some(source),
280            format!("link target does not resolve to a concept in the bundle: {raw}"),
281        );
282    }
283
284    report
285}
286
287/// The concept currently being checked, so each rule can emit diagnostics
288/// without repeating the path and id.
289struct Context<'a> {
290    report: &'a mut Report,
291    path: PathBuf,
292    id: ConceptId,
293}
294
295impl Context<'_> {
296    fn push_fixable(&mut self, severity: Severity, message: impl Into<String>, fixable: bool) {
297        self.report.diagnostics.push(Diagnostic {
298            severity,
299            path: Some(self.path.clone()),
300            concept: Some(self.id.clone()),
301            message: message.into(),
302            fixable,
303        });
304    }
305
306    fn push(&mut self, severity: Severity, message: impl Into<String>) {
307        self.push_fixable(severity, message, false);
308    }
309
310    fn error(&mut self, message: impl Into<String>) {
311        self.push(Severity::Error, message);
312    }
313
314    fn warn(&mut self, message: impl Into<String>) {
315        self.push(Severity::Warning, message);
316    }
317
318    fn warn_fixable(&mut self, message: impl Into<String>) {
319        self.push_fixable(Severity::Warning, message, true);
320    }
321
322    fn info(&mut self, message: impl Into<String>) {
323        self.push(Severity::Info, message);
324    }
325}
326
327impl Report {
328    fn add_fixable(
329        &mut self,
330        severity: Severity,
331        path: Option<PathBuf>,
332        concept: Option<ConceptId>,
333        message: String,
334        fixable: bool,
335    ) {
336        self.diagnostics.push(Diagnostic {
337            severity,
338            path,
339            concept,
340            message,
341            fixable,
342        });
343    }
344
345    fn add(
346        &mut self,
347        severity: Severity,
348        path: Option<PathBuf>,
349        concept: Option<ConceptId>,
350        message: String,
351    ) {
352        self.add_fixable(severity, path, concept, message, false);
353    }
354
355    fn error(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
356        self.add(Severity::Error, path, concept, message);
357    }
358
359    fn warn(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
360        self.add(Severity::Warning, path, concept, message);
361    }
362
363    fn info(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
364        self.add(Severity::Info, path, concept, message);
365    }
366}
367
368/// Recommended fields (`title`, `description`, `status`, `generated`).
369///
370/// Always a warning: conformance forbids rejecting a concept for a missing optional
371/// field, however much a producer wants it filled in.
372fn check_recommended(cx: &mut Context, doc: &Document) {
373    for field in doc.missing_recommended() {
374        // `runtime` also comes back from `missing_recommended`, but
375        // `check_computation` reports it with the reason it is required.
376        if field == "runtime" {
377            continue;
378        }
379        let fixable = field == "title" || field == "generated";
380        cx.push_fixable(
381            Severity::Warning,
382            format!("missing recommended frontmatter field `{field}`"),
383            fixable,
384        );
385    }
386}
387
388/// Checks that the document body is not empty.
389fn check_empty_body(cx: &mut Context, doc: &Document) {
390    if doc.body.trim().is_empty() {
391        cx.warn("body is empty; a concept should carry at least one line of prose or code");
392    }
393}
394
395/// The shape of `tags`.
396///
397/// Worth its own check because the failure is silent: the spec asks for "a YAML list
398/// of short strings", and a producer that writes `tags: a, b, c` gets one plain
399/// scalar, so [`Frontmatter::tags`] reads no tags at all and the concept
400/// disappears from every tag view.
401fn check_tags(cx: &mut Context, fm: &Frontmatter) {
402    let Some(value) = fm.get("tags").filter(|v| !v.is_empty_value()) else {
403        return;
404    };
405    if !matches!(value, Value::Sequence(_)) {
406        cx.warn(format!(
407            "`tags` should be a list of short strings, found {}; no tags are read from it",
408            type_name(value)
409        ));
410    }
411}
412
413/// `generated` and `verified`.
414fn check_trust(cx: &mut Context, fm: &Frontmatter, today: Option<Date>) {
415    let check_date = today.or_else(Date::today_utc);
416    let threshold_seconds = check_date.map(|d| (d.days_since_epoch() + 1) * 86_400);
417
418    if let Some(value) = fm.get("generated").filter(|v| !v.is_empty_value()) {
419        match fm.generated() {
420            None => cx.warn(format!(
421                "`generated` should be a `{{ by, at }}` mapping, found {}",
422                type_name(value)
423            )),
424            Some(generated) => {
425                if generated.by.is_none() {
426                    cx.warn("`generated.by` is required within `generated`");
427                }
428                match generated.at {
429                    None => {}
430                    Some(at) if !at.is_valid() => {
431                        let fixable = DateTime::parse(&at.raw).is_some();
432                        cx.push_fixable(
433                            Severity::Warning,
434                            format!(
435                                "`generated.at` is not an ISO-8601 datetime with an explicit offset: {:?}",
436                                at.raw
437                            ),
438                            fixable,
439                        );
440                    }
441                    Some(at) => {
442                        if let Some(threshold) = threshold_seconds
443                            && let Some(dt) = at.datetime
444                            && dt.to_utc_seconds() > threshold
445                        {
446                            cx.warn(format!("`generated.at` timestamp `{dt}` is in the future"));
447                        }
448                    }
449                }
450            }
451        }
452    }
453
454    let Some(value) = fm.get("verified").filter(|v| !v.is_empty_value()) else {
455        return;
456    };
457    if !matches!(value, Value::Sequence(_) | Value::Mapping(_)) {
458        cx.warn(format!(
459            "`verified` should be a list of `{{ by, at }}` events (a bare mapping is read as \
460             a one-element list), found {}",
461            type_name(value)
462        ));
463        return;
464    }
465    let events = fm.verified();
466    if events.is_empty() {
467        cx.warn("`verified` contains no `{ by, at }` events");
468    }
469    match value {
470        Value::Sequence(items) => {
471            for (i, item) in items.iter().enumerate() {
472                let Some(event) = Verification::from_value(item) else {
473                    cx.warn(format!(
474                        "`verified[{i}]` should be a mapping with `by` and `at`, found {}",
475                        type_name(item)
476                    ));
477                    continue;
478                };
479                check_verification_event(cx, i, &event, threshold_seconds);
480            }
481        }
482        Value::Mapping(_) => {
483            if let Some(event) = Verification::from_value(value) {
484                check_verification_event(cx, 0, &event, threshold_seconds);
485            }
486        }
487        _ => unreachable!("verified shape checked above"),
488    }
489
490    // Check if latest verified.at predates generated.at
491    if let Some(generated) = fm.generated()
492        && let Some(generated_at) = generated.at.as_ref().and_then(|a| a.datetime)
493        && let Some(latest) = okf_core::trust::latest_verification(&events)
494        && let Some(latest_at) = latest.at.as_ref().and_then(|a| a.datetime)
495        && latest_at < generated_at
496    {
497        cx.warn(format!(
498            "latest verification ({latest_at}) predates `generated.at` ({generated_at}); \
499             the current content was never re-verified"
500        ));
501    }
502}
503
504fn check_verification_event(
505    cx: &mut Context,
506    i: usize,
507    event: &Verification,
508    threshold_seconds: Option<i64>,
509) {
510    if event
511        .by
512        .as_ref()
513        .is_none_or(|by| by.as_str().trim().is_empty())
514    {
515        cx.warn(format!("`verified[{i}].by` is missing"));
516    }
517    match &event.at {
518        None => cx.warn(format!("`verified[{i}].at` is missing")),
519        Some(at) if !at.is_valid() => {
520            let fixable = DateTime::parse(&at.raw).is_some();
521            cx.push_fixable(
522                Severity::Warning,
523                format!(
524                    "`verified[{i}].at` is not an ISO-8601 datetime with an explicit offset: {:?}",
525                    at.raw
526                ),
527                fixable,
528            );
529        }
530        Some(at) => {
531            if let Some(threshold) = threshold_seconds
532                && let Some(dt) = at.datetime
533                && dt.to_utc_seconds() > threshold
534            {
535                cx.warn(format!(
536                    "`verified[{i}].at` timestamp `{dt}` is in the future"
537                ));
538            }
539        }
540    }
541}
542
543/// Validates syntax of fenced code blocks inside concept bodies.
544fn check_code_block_syntax(cx: &mut Context, doc: &Document) {
545    let blocks = crate::syntax::extract_fenced_code_blocks(&doc.body);
546    for block in blocks {
547        if let Some(lang_tag) = &block.language
548            && crate::syntax::Language::from_tag(lang_tag) != crate::syntax::Language::Unknown
549            && let Err(err) = crate::syntax::check_syntax(lang_tag, &block.code)
550        {
551            cx.warn(format!(
552                "code block syntax check failed ({}): {err}",
553                err.language
554            ));
555        }
556    }
557}
558
559/// Validates syntax of external script files referenced by Attested Computation contracts.
560fn check_computation_script_syntax(cx: &mut Context, bundle: &Bundle, doc: &Document) {
561    let Some(contract) = doc.attested_computation() else {
562        return;
563    };
564
565    if let okf_core::computation::ComputationSource::File(path) = &contract.computation
566        && let Some(resolved_path) = bundle.resolve_path_field(&cx.id, path)
567        && let Ok(content) = fs::read_to_string(&resolved_path)
568    {
569        let ext = std::path::Path::new(path)
570            .extension()
571            .and_then(|s| s.to_str())
572            .unwrap_or_default();
573        let lang_tag = contract.runtime.as_deref().unwrap_or(ext);
574        if let Err(err) = crate::syntax::check_syntax(lang_tag, &content) {
575            cx.warn(format!(
576                "computation script `{path}` syntax check failed ({}): {err}",
577                err.language
578            ));
579        }
580    }
581
582    if let Some(executor) = &contract.executor
583        && let Some(res) = &executor.resource
584        && let Some(resolved_path) = bundle.resolve_path_field(&cx.id, res)
585        && let Ok(content) = fs::read_to_string(&resolved_path)
586    {
587        let ext = std::path::Path::new(res)
588            .extension()
589            .and_then(|s| s.to_str())
590            .unwrap_or_default();
591        if let Err(err) = crate::syntax::check_syntax(ext, &content) {
592            cx.warn(format!(
593                "executor script `{res}` syntax check failed ({}): {err}",
594                err.language
595            ));
596        }
597    }
598
599    if let Some(attester) = &contract.attester
600        && let Some(res) = &attester.resource
601        && let Some(resolved_path) = bundle.resolve_path_field(&cx.id, res)
602        && let Ok(content) = fs::read_to_string(&resolved_path)
603    {
604        let ext = std::path::Path::new(res)
605            .extension()
606            .and_then(|s| s.to_str())
607            .unwrap_or_default();
608        if let Err(err) = crate::syntax::check_syntax(ext, &content) {
609            cx.warn(format!(
610                "attester script `{res}` syntax check failed ({}): {err}",
611                err.language
612            ));
613        }
614    }
615}
616
617/// `status` and `stale_after`.
618fn check_lifecycle(cx: &mut Context, fm: &Frontmatter, today: Option<Date>) {
619    let status = fm.status();
620    if !status.is_known() {
621        cx.warn(format!(
622            "unknown `status` value {:?}; the spec defines {} (consumers must still accept it)",
623            status.to_string(),
624            STATUS_VALUES.join(", ")
625        ));
626    }
627
628    let Some(stale_after) = fm.stale_after() else {
629        return;
630    };
631    match &stale_after.datetime {
632        Some(dt) if stale_after.is_valid() => {
633            if let Some(today) = today
634                && today.to_utc_datetime() >= *dt
635            {
636                cx.info(format!("stale since {stale_after}"));
637            }
638        }
639        _ => {
640            let fixable = DateTime::parse(&stale_after.raw).is_some();
641            cx.push_fixable(
642                Severity::Warning,
643                format!(
644                    "`stale_after` is not an ISO-8601 datetime with an explicit offset: {:?}",
645                    stale_after.raw
646                ),
647                fixable,
648            );
649        }
650    }
651}
652
653/// `sources` and `usage_window`.
654#[allow(clippy::too_many_lines)]
655fn check_provenance(cx: &mut Context, fm: &Frontmatter, today: Option<Date>) {
656    let check_date = today.or_else(Date::today_utc);
657    let threshold_seconds = check_date.map(|d| (d.days_since_epoch() + 1) * 86_400);
658
659    let Some(value) = fm.get("sources").filter(|v| !v.is_empty_value()) else {
660        if fm.get("usage_window").is_some() {
661            cx.warn("`usage_window` is present without `sources` to frame");
662        }
663        return;
664    };
665    if !matches!(value, Value::Sequence(_) | Value::Mapping(_)) {
666        cx.warn(format!(
667            "`sources` should be a list of entries, found {}",
668            type_name(value)
669        ));
670        return;
671    }
672
673    let shared_window = fm.usage_window();
674    if let Some(window) = &shared_window {
675        for (field, date) in [("from", &window.from), ("to", &window.to)] {
676            if let Some(d) = date.as_ref().filter(|d| !d.is_valid()) {
677                let fixable = DateTime::parse(&d.raw).is_some();
678                cx.push_fixable(
679                    Severity::Warning,
680                    format!(
681                        "`usage_window.{field}` is not an ISO-8601 datetime with an explicit offset: {:?}",
682                        d.raw
683                    ),
684                    fixable,
685                );
686            }
687        }
688    }
689
690    let mut seen_ids: HashSet<String> = HashSet::new();
691    let entries: Vec<(usize, Source)> = match value {
692        Value::Sequence(items) => items
693            .iter()
694            .enumerate()
695            .filter_map(|(i, item)| {
696                if item.as_mapping().is_none() {
697                    cx.warn(format!(
698                        "`sources[{i}]` should be a mapping entry, found {}",
699                        type_name(item)
700                    ));
701                    None
702                } else {
703                    Source::from_value(item).map(|source| (i, source))
704                }
705            })
706            .collect(),
707        Value::Mapping(_) => Source::from_value(value)
708            .into_iter()
709            .map(|source| (0, source))
710            .collect(),
711        _ => unreachable!("sources shape checked above"),
712    };
713    for (i, source) in &entries {
714        if source.resource_kind() == ResourceKind::Missing {
715            cx.warn(format!(
716                "`sources[{i}].resource` is required within an entry"
717            ));
718        }
719        if let Some(id) = &source.id
720            && !seen_ids.insert(id.clone())
721        {
722            cx.warn(format!(
723                "duplicate `sources[].id` {id:?}; ids are the join key for attribution"
724            ));
725        }
726        if let Some(last_modified) = source.last_modified.as_ref().filter(|d| !d.is_valid()) {
727            let fixable = DateTime::parse(&last_modified.raw).is_some();
728            cx.push_fixable(
729                Severity::Warning,
730                format!(
731                    "`sources[{i}].last_modified` is not an ISO-8601 datetime with an explicit offset: {:?}",
732                    last_modified.raw
733                ),
734                fixable,
735            );
736        } else if let Some(threshold) = threshold_seconds
737            && let Some(last_modified) = source.last_modified.as_ref().and_then(|d| d.datetime)
738            && last_modified.to_utc_seconds() > threshold
739        {
740            cx.warn(format!(
741                "`sources[{i}].last_modified` timestamp `{last_modified}` is in the future"
742            ));
743        }
744        if let Some(window) = &source.usage_window {
745            for (field, date) in [("from", &window.from), ("to", &window.to)] {
746                if let Some(d) = date.as_ref().filter(|d| !d.is_valid()) {
747                    let fixable = DateTime::parse(&d.raw).is_some();
748                    cx.push_fixable(
749                        Severity::Warning,
750                        format!(
751                            "`sources[{i}].usage_window.{field}` is not an ISO-8601 datetime with an explicit offset: {:?}",
752                            d.raw
753                        ),
754                        fixable,
755                    );
756                }
757            }
758        }
759        if source.usage_count.is_some()
760            && source
761                .effective_usage_window(shared_window.as_ref())
762                .is_none()
763        {
764            cx.warn(format!(
765                "`sources[{i}].usage_count` has no `usage_window` to frame it"
766            ));
767        }
768    }
769
770    // A non-integer `usage_count` is dropped by the typed reader, so check the
771    // raw values too.
772    if let Value::Sequence(items) = value {
773        for (i, item) in items.iter().enumerate() {
774            let raw = item.as_mapping().and_then(|m| m.get("usage_count"));
775            if let Some(raw) = raw.filter(|v| v.as_int().is_none()) {
776                cx.warn(format!(
777                    "`sources[{i}].usage_count` should be an integer, found {}",
778                    type_name(raw)
779                ));
780            }
781        }
782    }
783}
784
785/// Footnote attribution keyed to `sources[].id`.
786fn check_attribution(cx: &mut Context, doc: &Document) {
787    let has_sources = !doc.frontmatter.sources().is_empty();
788    for attribution in doc.attributions() {
789        if !attribution.is_resolved() && has_sources {
790            cx.warn(format!(
791                "footnote [^{}] matches no `sources[].id`; the label is the join key for \
792                 attribution",
793                attribution.label
794            ));
795        }
796        if attribution.references > 0 && attribution.definitions == 0 {
797            cx.warn(format!(
798                "footnote [^{}] is cited but never defined",
799                attribution.label
800            ));
801        }
802    }
803}
804
805/// v0.1 constructs that v0.2 supersedes.
806fn check_legacy(cx: &mut Context, doc: &Document) {
807    let fm = &doc.frontmatter;
808    if !is_blank(fm, "timestamp") {
809        if is_blank(fm, "generated") {
810            cx.warn_fixable("`timestamp` is superseded by `generated: { by, at }`");
811        } else {
812            cx.warn_fixable("`timestamp` is redundant alongside `generated` and should be removed");
813        }
814    }
815    if doc.has_legacy_citations() {
816        cx.warn_fixable(
817            "the body `# Citations` list is superseded by the `sources` frontmatter field",
818        );
819    }
820}
821
822/// The Attested Computation contract.
823fn check_computation(cx: &mut Context, bundle: &Bundle, doc: &Document) {
824    let fm = &doc.frontmatter;
825    let computation_keys = [
826        "runtime",
827        "parameters",
828        "computation",
829        "executor",
830        "attester",
831    ];
832
833    if !fm.is_attested_computation() {
834        let present: Vec<&str> = computation_keys
835            .iter()
836            .copied()
837            .filter(|k| !is_blank(fm, k))
838            .collect();
839        if !present.is_empty() {
840            cx.info(format!(
841                "carries computation field(s) `{}` but `type` is not `{ATTESTED_COMPUTATION_TYPE}`; \
842                 a sanctioned computation is its own concept",
843                present.join("`, `")
844            ));
845        }
846        return;
847    }
848
849    let Some(contract) = doc.attested_computation() else {
850        return;
851    };
852
853    if contract.runtime.is_none() {
854        cx.warn(
855            "`runtime` is required on an `Attested Computation`; it defines what `parameters` mean",
856        );
857    }
858    match &contract.computation {
859        ComputationSource::Missing => cx.warn(
860            "no computation: set `computation` to a path or add a `# Computation` block to the body",
861        ),
862        ComputationSource::File(_) if contract.has_redundant_inline => cx.warn(
863            "`computation` names a file and the body also has a `# Computation` block; the spec asks for one or the other",
864        ),
865        _ => {}
866    }
867
868    for (i, parameter) in contract.parameters.iter().enumerate() {
869        if parameter.name.is_none() {
870            cx.warn(format!("`parameters[{i}].name` is missing"));
871        }
872        if parameter.type_.is_none() {
873            cx.warn(format!("`parameters[{i}].type` is missing"));
874        }
875    }
876
877    match &contract.executor {
878        None => cx.warn("missing `executor`: nothing says how to run the computation"),
879        Some(executor) => {
880            if executor.resource.is_none() {
881                cx.warn("`executor.resource` is missing; it names the run instructions or code");
882            }
883            if executor.receipt.is_empty() {
884                cx.warn(
885                    "`executor.receipt` is empty; it declares the evidence the attester inspects",
886                );
887            }
888        }
889    }
890
891    match &contract.attester {
892        None => cx.warn("missing `attester`: nothing can check a run's receipt"),
893        Some(attester) if attester.resource.is_none() => {
894            cx.warn("`attester.resource` is missing; it names the deterministic check");
895        }
896        Some(_) => {}
897    }
898
899    check_attestation_resources(cx, bundle, doc);
900}
901
902fn check_attestation_resources(cx: &mut Context, bundle: &Bundle, doc: &Document) {
903    let Some(contract) = doc.attested_computation() else {
904        return;
905    };
906    if let Some(executor) = &contract.executor
907        && let Some(res) = &executor.resource
908        && !res.starts_with("http://")
909        && !res.starts_with("https://")
910        && bundle.resolve_path_field(&cx.id, res).is_none()
911    {
912        cx.warn(format!(
913            "`executor.resource` points to `{res}` which does not exist on disk"
914        ));
915    }
916    if let Some(attester) = &contract.attester
917        && let Some(res) = &attester.resource
918        && !res.starts_with("http://")
919        && !res.starts_with("https://")
920        && bundle.resolve_path_field(&cx.id, res).is_none()
921    {
922        cx.warn(format!(
923            "`attester.resource` points to `{res}` which does not exist on disk"
924        ));
925    }
926    if let okf_core::computation::ComputationSource::File(path) = &contract.computation
927        && !path.starts_with("http://")
928        && !path.starts_with("https://")
929        && bundle.resolve_path_field(&cx.id, path).is_none()
930    {
931        cx.warn(format!(
932            "`computation` file `{path}` does not exist on disk"
933        ));
934    }
935}
936
937fn check_links_to_deprecated(cx: &mut Context, bundle: &Bundle) {
938    let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
939    for link in bundle.links_from(&cx.id) {
940        if !link.exists || !warned.insert(link.target.clone()) {
941            continue;
942        }
943        if let Some(target) = bundle.get(&link.target)
944            && target.status().is_deprecated()
945        {
946            cx.warn(format!("links to deprecated concept `{}`", link.target));
947        }
948    }
949}
950
951/// Path-valued fields that point inside the bundle but resolve to nothing.
952/// Informational, since a bundle may legitimately be shipped without
953/// the files its executor or attester references.
954///
955/// `resource` is only checked when it is written unambiguously as a path
956/// (`/...`, `./...`, `../...`). A bare `resource` such as
957/// `acme.sales.orders` is an opaque asset identifier, not a promise that a file
958/// exists, and reporting it as a broken path would be noise.
959fn check_path_fields(cx: &mut Context, bundle: &Bundle, fm: &Frontmatter) {
960    let id = cx.id.clone();
961    for (field, raw) in fm.path_fields() {
962        let target = raw.trim();
963        let explicit_path =
964            target.starts_with('/') || target.starts_with("./") || target.starts_with("../");
965        if field == "resource" && !explicit_path {
966            continue;
967        }
968        if okf_core::links::field_path_candidates(target, &id).is_empty() {
969            continue; // a URI, nothing in the bundle to resolve
970        }
971        if bundle.resolve_path_field(&id, target).is_none() {
972            cx.info(format!(
973                "`{field}` does not resolve to a file in the bundle: {raw}"
974            ));
975        }
976    }
977}
978
979/// Concept-id segments outside the reference implementation's
980/// `[A-Za-z0-9_][A-Za-z0-9_.\-]*` convention.
981///
982/// Never an error. The spec places no character constraint on filenames and
983/// conformance is a question of frontmatter, so [`ConceptId`] accepts
984/// these names and the bundle stays conformant. It is still worth telling a
985/// producer: such a name has to be written as `<...>` or percent-encoded to be
986/// linked from markdown, and is not guaranteed to survive every filesystem
987/// unchanged.
988///
989/// Each distinct segment is reported once, against the first concept that uses
990/// it, so one awkwardly named directory does not warn on every file inside it.
991fn check_segment_portability(bundle: &Bundle, report: &mut Report) {
992    let mut seen: HashSet<&str> = HashSet::new();
993    for concept in bundle.concepts() {
994        for segment in concept.id.segments() {
995            if okf_core::concept_id::is_portable_segment(segment) || !seen.insert(segment) {
996                continue;
997            }
998            report.warn(
999                Some(concept.path.clone()),
1000                Some(concept.id.clone()),
1001                format!(
1002                    "concept-id segment {segment:?} is outside the conventional \
1003                     `[A-Za-z0-9_][A-Za-z0-9_.-]*` set; the bundle is still conformant, but \
1004                     such a name needs `<...>` or percent-encoded to link portably and is \
1005                     not guaranteed to survive every filesystem unchanged"
1006                ),
1007            );
1008        }
1009    }
1010}
1011
1012fn check_duplicate_titles(bundle: &Bundle, report: &mut Report) {
1013    let mut counts: HashMap<String, usize> = HashMap::new();
1014    for c in bundle.concepts() {
1015        if let Some(title) = c.document.frontmatter.title() {
1016            *counts.entry(title.into_owned()).or_default() += 1;
1017        }
1018    }
1019    for c in bundle.concepts() {
1020        if let Some(title) = c.document.frontmatter.title()
1021            && counts.get(title.as_ref()).copied().unwrap_or(0) > 1
1022        {
1023            report.warn(
1024                Some(c.path.clone()),
1025                Some(c.id.clone()),
1026                format!(
1027                    "`title` {title:?} is shared with another concept; titles should disambiguate"
1028                ),
1029            );
1030        }
1031    }
1032}
1033
1034fn check_circular_derivation(bundle: &Bundle, report: &mut Report) {
1035    let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
1036
1037    for concept in bundle.concepts() {
1038        if warned.contains(&concept.id) {
1039            continue;
1040        }
1041        let mut path = Vec::new();
1042        let mut visited = BTreeSet::new();
1043
1044        if find_derivation_cycle(bundle, &concept.id, &mut path, &mut visited) {
1045            for id in &path {
1046                warned.insert(id.clone());
1047            }
1048            let cycle_str: Vec<String> = path.iter().map(ToString::to_string).collect();
1049            report.warn(
1050                Some(concept.path.clone()),
1051                Some(concept.id.clone()),
1052                format!("circular concept derivation: {}", cycle_str.join(" ~> ")),
1053            );
1054        }
1055    }
1056}
1057
1058fn find_derivation_cycle(
1059    bundle: &Bundle,
1060    current: &ConceptId,
1061    path: &mut Vec<ConceptId>,
1062    visited: &mut BTreeSet<ConceptId>,
1063) -> bool {
1064    path.push(current.clone());
1065    visited.insert(current.clone());
1066
1067    for next in bundle.derived_from(current) {
1068        if path.first() == Some(next) || path.contains(next) {
1069            path.push((*next).clone());
1070            return true;
1071        }
1072        if !visited.contains(next) && find_derivation_cycle(bundle, next, path, visited) {
1073            return true;
1074        }
1075    }
1076
1077    path.pop();
1078    false
1079}
1080
1081/// Synthesizes the concept id an `index.md` would have if it were itself a
1082/// concept, so [`okf_core::links::Link::resolve_all`] can resolve its relative links against the
1083/// index's own directory.
1084#[must_use]
1085pub fn index_source_id(bundle_root: &Path, index_path: &Path) -> Option<ConceptId> {
1086    let rel = index_path.strip_prefix(bundle_root).ok()?;
1087    let mut segments: Vec<String> = rel
1088        .components()
1089        .filter_map(|c| match c {
1090            std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
1091            _ => None,
1092        })
1093        .collect();
1094    if let Some(last) = segments.last_mut()
1095        && let Some(stripped) = last.strip_suffix(".md")
1096    {
1097        *last = stripped.to_string();
1098    }
1099    ConceptId::new(segments).ok()
1100}
1101
1102/// Every link target an `index.md` lists, paired with the raw target as
1103/// written, resolved to a concept id whether or not that concept exists in the
1104/// bundle.
1105#[must_use]
1106pub fn index_listed_targets(bundle: &Bundle, index_path: &Path) -> Vec<(String, ConceptId)> {
1107    let mut out = Vec::new();
1108    let Some(source) = index_source_id(bundle.root(), index_path) else {
1109        return out;
1110    };
1111    let Ok(text) = fs::read_to_string(index_path) else {
1112        return out;
1113    };
1114    let Ok(doc) = Document::parse(&text) else {
1115        return out;
1116    };
1117    for link in doc.links() {
1118        for target in link.resolve_all(&source) {
1119            out.push((link.target.clone(), target));
1120        }
1121    }
1122    out
1123}
1124
1125/// `true` when a raw link target names a concept (a `.md` file or a bare id)
1126/// rather than a non-markdown resource such as `attester.py`.
1127#[must_use]
1128pub fn is_concept_link(raw: &str) -> bool {
1129    let t = raw.trim();
1130    if t.starts_with('#') || t.is_empty() {
1131        return false;
1132    }
1133    if okf_core::links::LinkKind::External == okf_core::links::Link::classify(t) {
1134        return false;
1135    }
1136    let before_anchor = t.split('#').next().unwrap_or(t);
1137    let basename = before_anchor.rsplit('/').next().unwrap_or(before_anchor);
1138    if okf_core::bundle::RESERVED_FILENAMES.contains(&basename) {
1139        return false;
1140    }
1141    #[allow(clippy::case_sensitive_file_extension_comparisons)]
1142    {
1143        basename.ends_with(".md") || !basename.contains('.')
1144    }
1145}
1146
1147fn check_stale_indexes(bundle: &Bundle, report: &mut Report) {
1148    for index_path in bundle.index_files() {
1149        let Some(dir) = index_path.parent() else {
1150            continue;
1151        };
1152        let Some(index_id) = index_source_id(bundle.root(), index_path) else {
1153            continue;
1154        };
1155        let index_dir = index_id.parent();
1156
1157        let actual: BTreeSet<ConceptId> = bundle
1158            .concepts()
1159            .iter()
1160            .filter(|c| c.path.parent() == Some(dir))
1161            .map(|c| c.id.clone())
1162            .collect();
1163
1164        let listed: BTreeSet<ConceptId> = index_listed_targets(bundle, index_path)
1165            .into_iter()
1166            .filter(|(raw, _)| is_concept_link(raw))
1167            .map(|(_, target)| target)
1168            .filter(|t| t.parent() == index_dir)
1169            .collect();
1170
1171        let missing_from_index: Vec<String> = actual
1172            .iter()
1173            .filter(|c| !listed.contains(*c))
1174            .map(ConceptId::to_string)
1175            .collect();
1176        let listed_but_not_on_disk: Vec<String> = listed
1177            .iter()
1178            .filter(|c| !actual.contains(*c))
1179            .map(ConceptId::to_string)
1180            .collect();
1181
1182        if missing_from_index.is_empty() && listed_but_not_on_disk.is_empty() {
1183            continue;
1184        }
1185
1186        let mut parts = Vec::new();
1187        if !missing_from_index.is_empty() {
1188            parts.push(format!(
1189                "missing from index: {}",
1190                missing_from_index.join(", ")
1191            ));
1192        }
1193        if !listed_but_not_on_disk.is_empty() {
1194            parts.push(format!(
1195                "listed but not on disk: {}",
1196                listed_but_not_on_disk.join(", ")
1197            ));
1198        }
1199
1200        report.add_fixable(
1201            Severity::Warning,
1202            Some(index_path.clone()),
1203            None,
1204            format!(
1205                "index.md is out of sync with its directory ({})",
1206                parts.join("; ")
1207            ),
1208            true,
1209        );
1210    }
1211}
1212
1213fn validate_reserved(bundle: &Bundle, report: &mut Report) {
1214    let root_index = bundle.root().join("index.md");
1215
1216    for path in bundle.index_files() {
1217        let text = match fs::read_to_string(path) {
1218            Ok(text) => text,
1219            Err(error) => {
1220                report.error(
1221                    Some(path.clone()),
1222                    None,
1223                    format!("unreadable reserved index.md: {error}"),
1224                );
1225                continue;
1226            }
1227        };
1228        let doc = match Document::parse(&text) {
1229            Ok(doc) => doc,
1230            Err(error) => {
1231                report.error(
1232                    Some(path.clone()),
1233                    None,
1234                    format!("unparseable reserved index.md: {error}"),
1235                );
1236                continue;
1237            }
1238        };
1239        if doc.frontmatter.is_empty() {
1240            continue;
1241        }
1242        // Frontmatter is only permitted in the bundle-root index.md, and only
1243        // to declare `okf_version`.
1244        let is_root = path == &root_index;
1245        if is_root {
1246            let only_version = doc
1247                .frontmatter
1248                .as_mapping()
1249                .keys()
1250                .all(|k| k == "okf_version");
1251            if !only_version {
1252                report.error(
1253                    Some(path.clone()),
1254                    None,
1255                    "root index.md frontmatter should declare only `okf_version`".to_string(),
1256                );
1257            }
1258        } else {
1259            report.error(
1260                Some(path.clone()),
1261                None,
1262                "index.md should not contain frontmatter".to_string(),
1263            );
1264        }
1265    }
1266
1267    for path in bundle.log_files() {
1268        let text = match fs::read_to_string(path) {
1269            Ok(text) => text,
1270            Err(error) => {
1271                report.error(
1272                    Some(path.clone()),
1273                    None,
1274                    format!("unreadable reserved log.md: {error}"),
1275                );
1276                continue;
1277            }
1278        };
1279        let log = Log::parse(&text);
1280        for issue in log.structural_errors(&text) {
1281            report.error(Some(path.clone()), None, issue);
1282        }
1283        for bad in log.invalid_dates() {
1284            report.error(
1285                Some(path.clone()),
1286                None,
1287                format!("log date heading is not ISO-8601 `YYYY-MM-DD`: {bad:?}"),
1288            );
1289        }
1290        let mut seen = HashMap::new();
1291        for day in &log.days {
1292            *seen.entry(day.date.clone()).or_insert(0) += 1;
1293        }
1294        for (date, count) in seen {
1295            if count > 1 {
1296                report.add_fixable(
1297                    Severity::Warning,
1298                    Some(path.clone()),
1299                    None,
1300                    format!(
1301                        "log.md contains duplicate date heading `## {date}` (entries should be grouped under a single heading)"
1302                    ),
1303                    true,
1304                );
1305            }
1306        }
1307    }
1308}
1309
1310/// The `okf_version` a bundle declares.
1311///
1312/// Never an error. The spec is explicit that a consumer which does not understand
1313/// the declared version should attempt best-effort consumption rather than
1314/// refusing the bundle.
1315fn check_declared_version(bundle: &Bundle, report: &mut Report) {
1316    let Some(declared) = bundle.okf_version() else {
1317        return;
1318    };
1319    let declared = declared.trim();
1320    if declared == okf_core::OKF_VERSION {
1321        return;
1322    }
1323    let message = if okf_core::SUPPORTED_OKF_VERSIONS.contains(&declared) {
1324        format!(
1325            "bundle targets OKF v{declared}; read as v{} under documented fallbacks",
1326            okf_core::OKF_VERSION
1327        )
1328    } else {
1329        format!(
1330            "bundle declares an unrecognized `okf_version: {declared}`; consuming it \
1331             best-effort as v{}",
1332            okf_core::OKF_VERSION
1333        )
1334    };
1335    report.info(Some(bundle.root().join("index.md")), None, message);
1336}
1337
1338fn is_blank(fm: &Frontmatter, key: &str) -> bool {
1339    fm.get(key).is_none_or(Value::is_empty_value)
1340}
1341
1342/// A short YAML type name, for diagnostics about a mis-shaped value.
1343const fn type_name(value: &Value) -> &'static str {
1344    match value {
1345        Value::Null => "null",
1346        Value::Bool(_) => "a boolean",
1347        Value::Int(_) => "an integer",
1348        Value::Float(_) => "a float",
1349        Value::String(_) => "a string",
1350        Value::Sequence(_) => "a list",
1351        Value::Mapping(_) => "a mapping",
1352    }
1353}
1354
1355/// Checks an ISO-8601 datetime with a time of day and an explicit UTC offset.
1356///
1357/// OKF's timestamp fields require a time of day with an explicit offset.
1358#[must_use]
1359pub fn is_iso8601_datetime(s: &str) -> bool {
1360    DateTime::parse(s)
1361        .is_some_and(|datetime| datetime.has_time && datetime.offset_minutes.is_some())
1362}
1363
1364fn check_link_anchors(cx: &mut Context, bundle: &Bundle, doc: &Document) {
1365    let own_slugs = concept_heading_slugs(doc);
1366
1367    for link in doc.links() {
1368        if link.kind == okf_core::LinkKind::Anchor {
1369            let anchor = link.target.trim_start_matches('#');
1370            if !anchor.is_empty() {
1371                let decoded = anchor.replace("%20", " ").replace('+', " ");
1372                let slug = okf_core::heading_slug(&decoded);
1373                if !own_slugs.contains(&slug)
1374                    && !own_slugs.contains(anchor)
1375                    && !own_slugs.contains(&decoded.to_lowercase())
1376                {
1377                    cx.warn(format!(
1378                        "internal link anchor `#{anchor}` does not match any heading in this document"
1379                    ));
1380                }
1381            }
1382        } else if let Some(anchor_idx) = link.target.find('#') {
1383            let anchor = &link.target[anchor_idx + 1..];
1384            if !anchor.is_empty()
1385                && let Some(target_id) = link.resolve(&cx.id)
1386                && let Some(target_concept) = bundle.get(&target_id)
1387            {
1388                let target_slugs = concept_heading_slugs(&target_concept.document);
1389                let decoded = anchor.replace("%20", " ").replace('+', " ");
1390                let slug = okf_core::heading_slug(&decoded);
1391                if !target_slugs.contains(&slug)
1392                    && !target_slugs.contains(anchor)
1393                    && !target_slugs.contains(&decoded.to_lowercase())
1394                {
1395                    cx.warn(format!(
1396                        "link anchor `#{anchor}` does not match any heading in target concept `{target_id}`"
1397                    ));
1398                }
1399            }
1400        }
1401    }
1402}
1403
1404fn concept_heading_slugs(doc: &Document) -> std::collections::HashSet<String> {
1405    let mut slugs = std::collections::HashSet::new();
1406    for heading in okf_core::extract_headings(&doc.body) {
1407        let text = heading.text;
1408        if !text.is_empty() {
1409            slugs.insert(heading.slug());
1410            slugs.insert(text.to_lowercase());
1411            slugs.insert(text.replace(['-', '_'], " ").to_lowercase());
1412        }
1413    }
1414    slugs
1415}