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