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  | inline `# Computation` code block syntax error on `Attested Computation`           |
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_inline_computation_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 the inline `# Computation` block of an Attested
544/// Computation.
545///
546/// Only the sanctioned computation is checked (§10.3): it is the code an agent
547/// is expected to execute, so whether it parses is a conformance question
548/// there. Every other fenced block is documentation, and documentation is
549/// routinely a fragment (a bare join condition, a formula, an elided snippet),
550/// so it is deliberately left alone rather than reported against a
551/// whole-statement grammar. The language comes from the fence tag, falling
552/// back to `runtime`; a language this build has no parser for (an unknown tag,
553/// or a parser compiled out by a disabled crate feature) is skipped.
554fn check_inline_computation_syntax(cx: &mut Context, doc: &Document) {
555    let Some(contract) = doc.attested_computation() else {
556        return;
557    };
558    let ComputationSource::Inline(inline) = &contract.computation else {
559        return;
560    };
561    let Some(lang_tag) = inline.language.as_deref().or(contract.runtime.as_deref()) else {
562        return;
563    };
564    if crate::syntax::Language::from_tag(lang_tag).is_supported()
565        && let Err(err) = crate::syntax::check_syntax(lang_tag, &inline.code)
566    {
567        cx.warn(format!(
568            "`# Computation` code block syntax check failed ({}): {err}",
569            err.language
570        ));
571    }
572}
573
574/// Validates syntax of external script files referenced by Attested Computation contracts.
575fn check_computation_script_syntax(cx: &mut Context, bundle: &Bundle, doc: &Document) {
576    let Some(contract) = doc.attested_computation() else {
577        return;
578    };
579
580    if let okf_core::computation::ComputationSource::File(path) = &contract.computation
581        && let Some(resolved_path) = bundle.resolve_path_field(&cx.id, path)
582        && let Ok(content) = fs::read_to_string(&resolved_path)
583    {
584        let ext = std::path::Path::new(path)
585            .extension()
586            .and_then(|s| s.to_str())
587            .unwrap_or_default();
588        let lang_tag = contract.runtime.as_deref().unwrap_or(ext);
589        if let Err(err) = crate::syntax::check_syntax(lang_tag, &content) {
590            cx.warn(format!(
591                "computation script `{path}` syntax check failed ({}): {err}",
592                err.language
593            ));
594        }
595    }
596
597    if let Some(executor) = &contract.executor
598        && let Some(res) = &executor.resource
599        && let Some(resolved_path) = bundle.resolve_path_field(&cx.id, res)
600        && let Ok(content) = fs::read_to_string(&resolved_path)
601    {
602        let ext = std::path::Path::new(res)
603            .extension()
604            .and_then(|s| s.to_str())
605            .unwrap_or_default();
606        if let Err(err) = crate::syntax::check_syntax(ext, &content) {
607            cx.warn(format!(
608                "executor script `{res}` syntax check failed ({}): {err}",
609                err.language
610            ));
611        }
612    }
613
614    if let Some(attester) = &contract.attester
615        && let Some(res) = &attester.resource
616        && let Some(resolved_path) = bundle.resolve_path_field(&cx.id, res)
617        && let Ok(content) = fs::read_to_string(&resolved_path)
618    {
619        let ext = std::path::Path::new(res)
620            .extension()
621            .and_then(|s| s.to_str())
622            .unwrap_or_default();
623        if let Err(err) = crate::syntax::check_syntax(ext, &content) {
624            cx.warn(format!(
625                "attester script `{res}` syntax check failed ({}): {err}",
626                err.language
627            ));
628        }
629    }
630}
631
632/// `status` and `stale_after`.
633fn check_lifecycle(cx: &mut Context, fm: &Frontmatter, today: Option<Date>) {
634    let status = fm.status();
635    if !status.is_known() {
636        cx.warn(format!(
637            "unknown `status` value {:?}; the spec defines {} (consumers must still accept it)",
638            status.to_string(),
639            STATUS_VALUES.join(", ")
640        ));
641    }
642
643    let Some(stale_after) = fm.stale_after() else {
644        return;
645    };
646    match &stale_after.datetime {
647        Some(dt) if stale_after.is_valid() => {
648            if let Some(today) = today
649                && today.to_utc_datetime() >= *dt
650            {
651                cx.info(format!("stale since {stale_after}"));
652            }
653        }
654        _ => {
655            let fixable = DateTime::parse(&stale_after.raw).is_some();
656            cx.push_fixable(
657                Severity::Warning,
658                format!(
659                    "`stale_after` is not an ISO-8601 datetime with an explicit offset: {:?}",
660                    stale_after.raw
661                ),
662                fixable,
663            );
664        }
665    }
666}
667
668/// `sources` and `usage_window`.
669#[allow(clippy::too_many_lines)]
670fn check_provenance(cx: &mut Context, fm: &Frontmatter, today: Option<Date>) {
671    let check_date = today.or_else(Date::today_utc);
672    let threshold_seconds = check_date.map(|d| (d.days_since_epoch() + 1) * 86_400);
673
674    let Some(value) = fm.get("sources").filter(|v| !v.is_empty_value()) else {
675        if fm.get("usage_window").is_some() {
676            cx.warn("`usage_window` is present without `sources` to frame");
677        }
678        return;
679    };
680    if !matches!(value, Value::Sequence(_) | Value::Mapping(_)) {
681        cx.warn(format!(
682            "`sources` should be a list of entries, found {}",
683            type_name(value)
684        ));
685        return;
686    }
687
688    let shared_window = fm.usage_window();
689    if let Some(window) = &shared_window {
690        for (field, date) in [("from", &window.from), ("to", &window.to)] {
691            if let Some(d) = date.as_ref().filter(|d| !d.is_valid()) {
692                let fixable = DateTime::parse(&d.raw).is_some();
693                cx.push_fixable(
694                    Severity::Warning,
695                    format!(
696                        "`usage_window.{field}` is not an ISO-8601 datetime with an explicit offset: {:?}",
697                        d.raw
698                    ),
699                    fixable,
700                );
701            }
702        }
703    }
704
705    let mut seen_ids: HashSet<String> = HashSet::new();
706    let entries: Vec<(usize, Source)> = match value {
707        Value::Sequence(items) => items
708            .iter()
709            .enumerate()
710            .filter_map(|(i, item)| {
711                if item.as_mapping().is_none() {
712                    cx.warn(format!(
713                        "`sources[{i}]` should be a mapping entry, found {}",
714                        type_name(item)
715                    ));
716                    None
717                } else {
718                    Source::from_value(item).map(|source| (i, source))
719                }
720            })
721            .collect(),
722        Value::Mapping(_) => Source::from_value(value)
723            .into_iter()
724            .map(|source| (0, source))
725            .collect(),
726        _ => unreachable!("sources shape checked above"),
727    };
728    for (i, source) in &entries {
729        if source.resource_kind() == ResourceKind::Missing {
730            cx.warn(format!(
731                "`sources[{i}].resource` is required within an entry"
732            ));
733        }
734        if let Some(id) = &source.id
735            && !seen_ids.insert(id.clone())
736        {
737            cx.warn(format!(
738                "duplicate `sources[].id` {id:?}; ids are the join key for attribution"
739            ));
740        }
741        if let Some(last_modified) = source.last_modified.as_ref().filter(|d| !d.is_valid()) {
742            let fixable = DateTime::parse(&last_modified.raw).is_some();
743            cx.push_fixable(
744                Severity::Warning,
745                format!(
746                    "`sources[{i}].last_modified` is not an ISO-8601 datetime with an explicit offset: {:?}",
747                    last_modified.raw
748                ),
749                fixable,
750            );
751        } else if let Some(threshold) = threshold_seconds
752            && let Some(last_modified) = source.last_modified.as_ref().and_then(|d| d.datetime)
753            && last_modified.to_utc_seconds() > threshold
754        {
755            cx.warn(format!(
756                "`sources[{i}].last_modified` timestamp `{last_modified}` is in the future"
757            ));
758        }
759        if let Some(window) = &source.usage_window {
760            for (field, date) in [("from", &window.from), ("to", &window.to)] {
761                if let Some(d) = date.as_ref().filter(|d| !d.is_valid()) {
762                    let fixable = DateTime::parse(&d.raw).is_some();
763                    cx.push_fixable(
764                        Severity::Warning,
765                        format!(
766                            "`sources[{i}].usage_window.{field}` is not an ISO-8601 datetime with an explicit offset: {:?}",
767                            d.raw
768                        ),
769                        fixable,
770                    );
771                }
772            }
773        }
774        if source.usage_count.is_some()
775            && source
776                .effective_usage_window(shared_window.as_ref())
777                .is_none()
778        {
779            cx.warn(format!(
780                "`sources[{i}].usage_count` has no `usage_window` to frame it"
781            ));
782        }
783    }
784
785    // A non-integer `usage_count` is dropped by the typed reader, so check the
786    // raw values too.
787    if let Value::Sequence(items) = value {
788        for (i, item) in items.iter().enumerate() {
789            let raw = item.as_mapping().and_then(|m| m.get("usage_count"));
790            if let Some(raw) = raw.filter(|v| v.as_int().is_none()) {
791                cx.warn(format!(
792                    "`sources[{i}].usage_count` should be an integer, found {}",
793                    type_name(raw)
794                ));
795            }
796        }
797    }
798}
799
800/// Footnote attribution keyed to `sources[].id`.
801fn check_attribution(cx: &mut Context, doc: &Document) {
802    let has_sources = !doc.frontmatter.sources().is_empty();
803    for attribution in doc.attributions() {
804        if !attribution.is_resolved() && has_sources {
805            cx.warn(format!(
806                "footnote [^{}] matches no `sources[].id`; the label is the join key for \
807                 attribution",
808                attribution.label
809            ));
810        }
811        if attribution.references > 0 && attribution.definitions == 0 {
812            cx.warn(format!(
813                "footnote [^{}] is cited but never defined",
814                attribution.label
815            ));
816        }
817    }
818}
819
820/// v0.1 constructs that v0.2 supersedes.
821fn check_legacy(cx: &mut Context, doc: &Document) {
822    let fm = &doc.frontmatter;
823    if !is_blank(fm, "timestamp") {
824        if is_blank(fm, "generated") {
825            cx.warn_fixable("`timestamp` is superseded by `generated: { by, at }`");
826        } else {
827            cx.warn_fixable("`timestamp` is redundant alongside `generated` and should be removed");
828        }
829    }
830    if doc.has_legacy_citations() {
831        cx.warn_fixable(
832            "the body `# Citations` list is superseded by the `sources` frontmatter field",
833        );
834    }
835}
836
837/// The Attested Computation contract.
838fn check_computation(cx: &mut Context, bundle: &Bundle, doc: &Document) {
839    let fm = &doc.frontmatter;
840    let computation_keys = [
841        "runtime",
842        "parameters",
843        "computation",
844        "executor",
845        "attester",
846    ];
847
848    if !fm.is_attested_computation() {
849        let present: Vec<&str> = computation_keys
850            .iter()
851            .copied()
852            .filter(|k| !is_blank(fm, k))
853            .collect();
854        if !present.is_empty() {
855            cx.info(format!(
856                "carries computation field(s) `{}` but `type` is not `{ATTESTED_COMPUTATION_TYPE}`; \
857                 a sanctioned computation is its own concept",
858                present.join("`, `")
859            ));
860        }
861        return;
862    }
863
864    let Some(contract) = doc.attested_computation() else {
865        return;
866    };
867
868    if contract.runtime.is_none() {
869        cx.warn(
870            "`runtime` is required on an `Attested Computation`; it defines what `parameters` mean",
871        );
872    }
873    match &contract.computation {
874        ComputationSource::Missing => cx.warn(
875            "no computation: set `computation` to a path or add a `# Computation` block to the body",
876        ),
877        ComputationSource::File(_) if contract.has_redundant_inline => cx.warn(
878            "`computation` names a file and the body also has a `# Computation` block; the spec asks for one or the other",
879        ),
880        _ => {}
881    }
882
883    for (i, parameter) in contract.parameters.iter().enumerate() {
884        if parameter.name.is_none() {
885            cx.warn(format!("`parameters[{i}].name` is missing"));
886        }
887        if parameter.type_.is_none() {
888            cx.warn(format!("`parameters[{i}].type` is missing"));
889        }
890    }
891
892    match &contract.executor {
893        None => cx.warn("missing `executor`: nothing says how to run the computation"),
894        Some(executor) => {
895            if executor.resource.is_none() {
896                cx.warn("`executor.resource` is missing; it names the run instructions or code");
897            }
898            if executor.receipt.is_empty() {
899                cx.warn(
900                    "`executor.receipt` is empty; it declares the evidence the attester inspects",
901                );
902            }
903        }
904    }
905
906    match &contract.attester {
907        None => cx.warn("missing `attester`: nothing can check a run's receipt"),
908        Some(attester) if attester.resource.is_none() => {
909            cx.warn("`attester.resource` is missing; it names the deterministic check");
910        }
911        Some(_) => {}
912    }
913
914    check_attestation_resources(cx, bundle, doc);
915}
916
917fn check_attestation_resources(cx: &mut Context, bundle: &Bundle, doc: &Document) {
918    let Some(contract) = doc.attested_computation() else {
919        return;
920    };
921    if let Some(executor) = &contract.executor
922        && let Some(res) = &executor.resource
923        && !res.starts_with("http://")
924        && !res.starts_with("https://")
925        && bundle.resolve_path_field(&cx.id, res).is_none()
926    {
927        cx.warn(format!(
928            "`executor.resource` points to `{res}` which does not exist on disk"
929        ));
930    }
931    if let Some(attester) = &contract.attester
932        && let Some(res) = &attester.resource
933        && !res.starts_with("http://")
934        && !res.starts_with("https://")
935        && bundle.resolve_path_field(&cx.id, res).is_none()
936    {
937        cx.warn(format!(
938            "`attester.resource` points to `{res}` which does not exist on disk"
939        ));
940    }
941    if let okf_core::computation::ComputationSource::File(path) = &contract.computation
942        && !path.starts_with("http://")
943        && !path.starts_with("https://")
944        && bundle.resolve_path_field(&cx.id, path).is_none()
945    {
946        cx.warn(format!(
947            "`computation` file `{path}` does not exist on disk"
948        ));
949    }
950}
951
952fn check_links_to_deprecated(cx: &mut Context, bundle: &Bundle) {
953    let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
954    for link in bundle.links_from(&cx.id) {
955        if !link.exists || !warned.insert(link.target.clone()) {
956            continue;
957        }
958        if let Some(target) = bundle.get(&link.target)
959            && target.status().is_deprecated()
960        {
961            cx.warn(format!("links to deprecated concept `{}`", link.target));
962        }
963    }
964}
965
966/// Path-valued fields that point inside the bundle but resolve to nothing.
967/// Informational, since a bundle may legitimately be shipped without
968/// the files its executor or attester references.
969///
970/// `resource` is only checked when it is written unambiguously as a path
971/// (`/...`, `./...`, `../...`). A bare `resource` such as
972/// `acme.sales.orders` is an opaque asset identifier, not a promise that a file
973/// exists, and reporting it as a broken path would be noise.
974fn check_path_fields(cx: &mut Context, bundle: &Bundle, fm: &Frontmatter) {
975    let id = cx.id.clone();
976    for (field, raw) in fm.path_fields() {
977        let target = raw.trim();
978        let explicit_path =
979            target.starts_with('/') || target.starts_with("./") || target.starts_with("../");
980        if field == "resource" && !explicit_path {
981            continue;
982        }
983        if okf_core::links::field_path_candidates(target, &id).is_empty() {
984            continue; // a URI, nothing in the bundle to resolve
985        }
986        if bundle.resolve_path_field(&id, target).is_none() {
987            cx.info(format!(
988                "`{field}` does not resolve to a file in the bundle: {raw}"
989            ));
990        }
991    }
992}
993
994/// Concept-id segments outside the reference implementation's
995/// `[A-Za-z0-9_][A-Za-z0-9_.\-]*` convention.
996///
997/// Never an error. The spec places no character constraint on filenames and
998/// conformance is a question of frontmatter, so [`ConceptId`] accepts
999/// these names and the bundle stays conformant. It is still worth telling a
1000/// producer: such a name has to be written as `<...>` or percent-encoded to be
1001/// linked from markdown, and is not guaranteed to survive every filesystem
1002/// unchanged.
1003///
1004/// Each distinct segment is reported once, against the first concept that uses
1005/// it, so one awkwardly named directory does not warn on every file inside it.
1006fn check_segment_portability(bundle: &Bundle, report: &mut Report) {
1007    let mut seen: HashSet<&str> = HashSet::new();
1008    for concept in bundle.concepts() {
1009        for segment in concept.id.segments() {
1010            if okf_core::concept_id::is_portable_segment(segment) || !seen.insert(segment) {
1011                continue;
1012            }
1013            report.warn(
1014                Some(concept.path.clone()),
1015                Some(concept.id.clone()),
1016                format!(
1017                    "concept-id segment {segment:?} is outside the conventional \
1018                     `[A-Za-z0-9_][A-Za-z0-9_.-]*` set; the bundle is still conformant, but \
1019                     such a name needs `<...>` or percent-encoded to link portably and is \
1020                     not guaranteed to survive every filesystem unchanged"
1021                ),
1022            );
1023        }
1024    }
1025}
1026
1027fn check_duplicate_titles(bundle: &Bundle, report: &mut Report) {
1028    let mut counts: HashMap<String, usize> = HashMap::new();
1029    for c in bundle.concepts() {
1030        if let Some(title) = c.document.frontmatter.title() {
1031            *counts.entry(title.into_owned()).or_default() += 1;
1032        }
1033    }
1034    for c in bundle.concepts() {
1035        if let Some(title) = c.document.frontmatter.title()
1036            && counts.get(title.as_ref()).copied().unwrap_or(0) > 1
1037        {
1038            report.warn(
1039                Some(c.path.clone()),
1040                Some(c.id.clone()),
1041                format!(
1042                    "`title` {title:?} is shared with another concept; titles should disambiguate"
1043                ),
1044            );
1045        }
1046    }
1047}
1048
1049fn check_circular_derivation(bundle: &Bundle, report: &mut Report) {
1050    let mut warned: BTreeSet<ConceptId> = BTreeSet::new();
1051
1052    for concept in bundle.concepts() {
1053        if warned.contains(&concept.id) {
1054            continue;
1055        }
1056        let mut path = Vec::new();
1057        let mut visited = BTreeSet::new();
1058
1059        if find_derivation_cycle(bundle, &concept.id, &mut path, &mut visited) {
1060            for id in &path {
1061                warned.insert(id.clone());
1062            }
1063            let cycle_str: Vec<String> = path.iter().map(ToString::to_string).collect();
1064            report.warn(
1065                Some(concept.path.clone()),
1066                Some(concept.id.clone()),
1067                format!("circular concept derivation: {}", cycle_str.join(" ~> ")),
1068            );
1069        }
1070    }
1071}
1072
1073fn find_derivation_cycle(
1074    bundle: &Bundle,
1075    current: &ConceptId,
1076    path: &mut Vec<ConceptId>,
1077    visited: &mut BTreeSet<ConceptId>,
1078) -> bool {
1079    path.push(current.clone());
1080    visited.insert(current.clone());
1081
1082    for next in bundle.derived_from(current) {
1083        if path.first() == Some(next) || path.contains(next) {
1084            path.push((*next).clone());
1085            return true;
1086        }
1087        if !visited.contains(next) && find_derivation_cycle(bundle, next, path, visited) {
1088            return true;
1089        }
1090    }
1091
1092    path.pop();
1093    false
1094}
1095
1096/// Synthesizes the concept id an `index.md` would have if it were itself a
1097/// concept, so [`okf_core::links::Link::resolve_all`] can resolve its relative links against the
1098/// index's own directory.
1099#[must_use]
1100pub fn index_source_id(bundle_root: &Path, index_path: &Path) -> Option<ConceptId> {
1101    let rel = index_path.strip_prefix(bundle_root).ok()?;
1102    let mut segments: Vec<String> = rel
1103        .components()
1104        .filter_map(|c| match c {
1105            std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
1106            _ => None,
1107        })
1108        .collect();
1109    if let Some(last) = segments.last_mut()
1110        && let Some(stripped) = last.strip_suffix(".md")
1111    {
1112        *last = stripped.to_string();
1113    }
1114    ConceptId::new(segments).ok()
1115}
1116
1117/// Every link target an `index.md` lists, paired with the raw target as
1118/// written, resolved to a concept id whether or not that concept exists in the
1119/// bundle.
1120#[must_use]
1121pub fn index_listed_targets(bundle: &Bundle, index_path: &Path) -> Vec<(String, ConceptId)> {
1122    let mut out = Vec::new();
1123    let Some(source) = index_source_id(bundle.root(), index_path) else {
1124        return out;
1125    };
1126    let Ok(text) = fs::read_to_string(index_path) else {
1127        return out;
1128    };
1129    let Ok(doc) = Document::parse(&text) else {
1130        return out;
1131    };
1132    for link in doc.links() {
1133        for target in link.resolve_all(&source) {
1134            out.push((link.target.clone(), target));
1135        }
1136    }
1137    out
1138}
1139
1140/// `true` when a raw link target names a concept (a `.md` file or a bare id)
1141/// rather than a non-markdown resource such as `attester.py`.
1142#[must_use]
1143pub fn is_concept_link(raw: &str) -> bool {
1144    let t = raw.trim();
1145    if t.starts_with('#') || t.is_empty() {
1146        return false;
1147    }
1148    if okf_core::links::LinkKind::External == okf_core::links::Link::classify(t) {
1149        return false;
1150    }
1151    let before_anchor = t.split('#').next().unwrap_or(t);
1152    let basename = before_anchor.rsplit('/').next().unwrap_or(before_anchor);
1153    if okf_core::bundle::RESERVED_FILENAMES.contains(&basename) {
1154        return false;
1155    }
1156    #[allow(clippy::case_sensitive_file_extension_comparisons)]
1157    {
1158        basename.ends_with(".md") || !basename.contains('.')
1159    }
1160}
1161
1162fn check_stale_indexes(bundle: &Bundle, report: &mut Report) {
1163    for index_path in bundle.index_files() {
1164        let Some(dir) = index_path.parent() else {
1165            continue;
1166        };
1167        let Some(index_id) = index_source_id(bundle.root(), index_path) else {
1168            continue;
1169        };
1170        let index_dir = index_id.parent();
1171
1172        let actual: BTreeSet<ConceptId> = bundle
1173            .concepts()
1174            .iter()
1175            .filter(|c| c.path.parent() == Some(dir))
1176            .map(|c| c.id.clone())
1177            .collect();
1178
1179        let listed: BTreeSet<ConceptId> = index_listed_targets(bundle, index_path)
1180            .into_iter()
1181            .filter(|(raw, _)| is_concept_link(raw))
1182            .map(|(_, target)| target)
1183            .filter(|t| t.parent() == index_dir)
1184            .collect();
1185
1186        let missing_from_index: Vec<String> = actual
1187            .iter()
1188            .filter(|c| !listed.contains(*c))
1189            .map(ConceptId::to_string)
1190            .collect();
1191        let listed_but_not_on_disk: Vec<String> = listed
1192            .iter()
1193            .filter(|c| !actual.contains(*c))
1194            .map(ConceptId::to_string)
1195            .collect();
1196
1197        if missing_from_index.is_empty() && listed_but_not_on_disk.is_empty() {
1198            continue;
1199        }
1200
1201        let mut parts = Vec::new();
1202        if !missing_from_index.is_empty() {
1203            parts.push(format!(
1204                "missing from index: {}",
1205                missing_from_index.join(", ")
1206            ));
1207        }
1208        if !listed_but_not_on_disk.is_empty() {
1209            parts.push(format!(
1210                "listed but not on disk: {}",
1211                listed_but_not_on_disk.join(", ")
1212            ));
1213        }
1214
1215        report.add_fixable(
1216            Severity::Warning,
1217            Some(index_path.clone()),
1218            None,
1219            format!(
1220                "index.md is out of sync with its directory ({})",
1221                parts.join("; ")
1222            ),
1223            true,
1224        );
1225    }
1226}
1227
1228fn validate_reserved(bundle: &Bundle, report: &mut Report) {
1229    let root_index = bundle.root().join("index.md");
1230
1231    for path in bundle.index_files() {
1232        let text = match fs::read_to_string(path) {
1233            Ok(text) => text,
1234            Err(error) => {
1235                report.error(
1236                    Some(path.clone()),
1237                    None,
1238                    format!("unreadable reserved index.md: {error}"),
1239                );
1240                continue;
1241            }
1242        };
1243        let doc = match Document::parse(&text) {
1244            Ok(doc) => doc,
1245            Err(error) => {
1246                report.error(
1247                    Some(path.clone()),
1248                    None,
1249                    format!("unparseable reserved index.md: {error}"),
1250                );
1251                continue;
1252            }
1253        };
1254        if doc.frontmatter.is_empty() {
1255            continue;
1256        }
1257        // Frontmatter is only permitted in the bundle-root index.md, and only
1258        // to declare `okf_version`.
1259        let is_root = path == &root_index;
1260        if is_root {
1261            let only_version = doc
1262                .frontmatter
1263                .as_mapping()
1264                .keys()
1265                .all(|k| k == "okf_version");
1266            if !only_version {
1267                report.error(
1268                    Some(path.clone()),
1269                    None,
1270                    "root index.md frontmatter should declare only `okf_version`".to_string(),
1271                );
1272            }
1273        } else {
1274            report.error(
1275                Some(path.clone()),
1276                None,
1277                "index.md should not contain frontmatter".to_string(),
1278            );
1279        }
1280    }
1281
1282    for path in bundle.log_files() {
1283        let text = match fs::read_to_string(path) {
1284            Ok(text) => text,
1285            Err(error) => {
1286                report.error(
1287                    Some(path.clone()),
1288                    None,
1289                    format!("unreadable reserved log.md: {error}"),
1290                );
1291                continue;
1292            }
1293        };
1294        let log = Log::parse(&text);
1295        for issue in log.structural_errors(&text) {
1296            report.error(Some(path.clone()), None, issue);
1297        }
1298        for bad in log.invalid_dates() {
1299            report.error(
1300                Some(path.clone()),
1301                None,
1302                format!("log date heading is not ISO-8601 `YYYY-MM-DD`: {bad:?}"),
1303            );
1304        }
1305        let mut seen = HashMap::new();
1306        for day in &log.days {
1307            *seen.entry(day.date.clone()).or_insert(0) += 1;
1308        }
1309        for (date, count) in seen {
1310            if count > 1 {
1311                report.add_fixable(
1312                    Severity::Warning,
1313                    Some(path.clone()),
1314                    None,
1315                    format!(
1316                        "log.md contains duplicate date heading `## {date}` (entries should be grouped under a single heading)"
1317                    ),
1318                    true,
1319                );
1320            }
1321        }
1322    }
1323}
1324
1325/// The `okf_version` a bundle declares.
1326///
1327/// Never an error. The spec is explicit that a consumer which does not understand
1328/// the declared version should attempt best-effort consumption rather than
1329/// refusing the bundle.
1330fn check_declared_version(bundle: &Bundle, report: &mut Report) {
1331    let Some(declared) = bundle.okf_version() else {
1332        return;
1333    };
1334    let declared = declared.trim();
1335    if declared == okf_core::OKF_VERSION {
1336        return;
1337    }
1338    let message = if okf_core::SUPPORTED_OKF_VERSIONS.contains(&declared) {
1339        format!(
1340            "bundle targets OKF v{declared}; read as v{} under documented fallbacks",
1341            okf_core::OKF_VERSION
1342        )
1343    } else {
1344        format!(
1345            "bundle declares an unrecognized `okf_version: {declared}`; consuming it \
1346             best-effort as v{}",
1347            okf_core::OKF_VERSION
1348        )
1349    };
1350    report.info(Some(bundle.root().join("index.md")), None, message);
1351}
1352
1353fn is_blank(fm: &Frontmatter, key: &str) -> bool {
1354    fm.get(key).is_none_or(Value::is_empty_value)
1355}
1356
1357/// A short YAML type name, for diagnostics about a mis-shaped value.
1358const fn type_name(value: &Value) -> &'static str {
1359    match value {
1360        Value::Null => "null",
1361        Value::Bool(_) => "a boolean",
1362        Value::Int(_) => "an integer",
1363        Value::Float(_) => "a float",
1364        Value::String(_) => "a string",
1365        Value::Sequence(_) => "a list",
1366        Value::Mapping(_) => "a mapping",
1367    }
1368}
1369
1370/// Checks an ISO-8601 datetime with a time of day and an explicit UTC offset.
1371///
1372/// OKF's timestamp fields require a time of day with an explicit offset.
1373#[must_use]
1374pub fn is_iso8601_datetime(s: &str) -> bool {
1375    DateTime::parse(s)
1376        .is_some_and(|datetime| datetime.has_time && datetime.offset_minutes.is_some())
1377}
1378
1379fn check_link_anchors(cx: &mut Context, bundle: &Bundle, doc: &Document) {
1380    let own_slugs = concept_heading_slugs(doc);
1381
1382    for link in doc.links() {
1383        if link.kind == okf_core::LinkKind::Anchor {
1384            let anchor = link.target.trim_start_matches('#');
1385            if !anchor.is_empty() {
1386                let decoded = anchor.replace("%20", " ").replace('+', " ");
1387                let slug = okf_core::heading_slug(&decoded);
1388                if !own_slugs.contains(&slug)
1389                    && !own_slugs.contains(anchor)
1390                    && !own_slugs.contains(&decoded.to_lowercase())
1391                {
1392                    cx.warn(format!(
1393                        "internal link anchor `#{anchor}` does not match any heading in this document"
1394                    ));
1395                }
1396            }
1397        } else if let Some(anchor_idx) = link.target.find('#') {
1398            let anchor = &link.target[anchor_idx + 1..];
1399            if !anchor.is_empty()
1400                && let Some(target_id) = link.resolve(&cx.id)
1401                && let Some(target_concept) = bundle.get(&target_id)
1402            {
1403                let target_slugs = concept_heading_slugs(&target_concept.document);
1404                let decoded = anchor.replace("%20", " ").replace('+', " ");
1405                let slug = okf_core::heading_slug(&decoded);
1406                if !target_slugs.contains(&slug)
1407                    && !target_slugs.contains(anchor)
1408                    && !target_slugs.contains(&decoded.to_lowercase())
1409                {
1410                    cx.warn(format!(
1411                        "link anchor `#{anchor}` does not match any heading in target concept `{target_id}`"
1412                    ));
1413                }
1414            }
1415        }
1416    }
1417}
1418
1419fn concept_heading_slugs(doc: &Document) -> std::collections::HashSet<String> {
1420    let mut slugs = std::collections::HashSet::new();
1421    for heading in okf_core::extract_headings(&doc.body) {
1422        let text = heading.text;
1423        if !text.is_empty() {
1424            slugs.insert(heading.slug());
1425            slugs.insert(text.to_lowercase());
1426            slugs.insert(text.replace(['-', '_'], " ").to_lowercase());
1427        }
1428    }
1429    slugs
1430}