1use okf_core::bundle::Bundle;
20use okf_core::computation::{ATTESTED_COMPUTATION_TYPE, ComputationSource};
21use okf_core::concept_id::ConceptId;
22use okf_core::date::{Date, DateTime};
23use okf_core::document::Document;
24use okf_core::frontmatter::Frontmatter;
25use okf_core::log::Log;
26use okf_core::provenance::{ResourceKind, Source};
27use okf_core::trust::{STATUS_VALUES, Verification};
28use okf_core::yaml::Value;
29use std::collections::HashSet;
30use std::fs;
31use std::path::PathBuf;
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum Severity {
36 Error,
38 Warning,
40 Info,
42}
43
44impl std::fmt::Display for Severity {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.write_str(match self {
47 Self::Error => "error",
48 Self::Warning => "warning",
49 Self::Info => "info",
50 })
51 }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Diagnostic {
57 pub severity: Severity,
59 pub path: Option<PathBuf>,
61 pub concept: Option<ConceptId>,
63 pub message: String,
65 pub fixable: bool,
67}
68
69impl std::fmt::Display for Diagnostic {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 write!(f, "[{}] ", self.severity)?;
72 if let Some(p) = &self.path {
73 write!(f, "{}: ", p.display())?;
74 } else if let Some(c) = &self.concept {
75 write!(f, "{c}: ")?;
76 }
77 f.write_str(&self.message)
78 }
79}
80
81#[derive(Clone, Debug, Default)]
83pub struct Report {
84 pub diagnostics: Vec<Diagnostic>,
86}
87
88impl Report {
89 #[must_use]
92 pub fn is_conformant(&self) -> bool {
93 !self
94 .diagnostics
95 .iter()
96 .any(|d| d.severity == Severity::Error)
97 }
98
99 pub fn of(&self, severity: Severity) -> impl Iterator<Item = &Diagnostic> {
101 self.diagnostics
102 .iter()
103 .filter(move |d| d.severity == severity)
104 }
105
106 #[must_use]
108 pub fn error_count(&self) -> usize {
109 self.of(Severity::Error).count()
110 }
111
112 #[must_use]
114 pub fn warning_count(&self) -> usize {
115 self.of(Severity::Warning).count()
116 }
117
118 #[must_use]
120 pub fn fixable_count(&self) -> usize {
121 self.diagnostics.iter().filter(|d| d.fixable).count()
122 }
123}
124
125#[must_use]
130pub fn validate_bundle(bundle: &Bundle) -> Report {
131 validate_bundle_at(bundle, None)
132}
133
134#[must_use]
137pub fn validate_bundle_at(bundle: &Bundle, today: Option<Date>) -> Report {
138 let mut report = Report::default();
139
140 for (path, error) in bundle.parse_errors() {
142 report.error(
143 Some(path.clone()),
144 None,
145 format!("unparseable concept document: {error}"),
146 );
147 }
148
149 for concept in bundle.concepts() {
152 let mut cx = Context {
153 report: &mut report,
154 path: concept.path.clone(),
155 id: concept.id.clone(),
156 };
157 let fm = &concept.document.frontmatter;
158
159 if concept.document.validate().is_err() {
160 if fm
161 .get("type")
162 .is_some_and(|value| value.as_display_str().is_none())
163 {
164 cx.error("`type` must be a non-empty scalar");
165 } else {
166 cx.error("missing required frontmatter field `type`");
167 }
168 }
169 check_recommended(&mut cx, &concept.document);
170 check_tags(&mut cx, fm);
171 check_trust(&mut cx, fm);
172 check_lifecycle(&mut cx, fm, today);
173 check_provenance(&mut cx, fm);
174 check_attribution(&mut cx, &concept.document);
175 check_legacy(&mut cx, &concept.document);
176 check_computation(&mut cx, &concept.document);
177 check_path_fields(&mut cx, bundle, fm);
178 }
179
180 check_segment_portability(bundle, &mut report);
182 validate_reserved(bundle, &mut report);
183 check_declared_version(bundle, &mut report);
184
185 for (source, raw) in bundle.broken_links() {
187 report.info(
188 None,
189 Some(source),
190 format!("link target does not resolve to a concept in the bundle: {raw}"),
191 );
192 }
193
194 report
195}
196
197struct Context<'a> {
200 report: &'a mut Report,
201 path: PathBuf,
202 id: ConceptId,
203}
204
205impl Context<'_> {
206 fn push_fixable(&mut self, severity: Severity, message: impl Into<String>, fixable: bool) {
207 self.report.diagnostics.push(Diagnostic {
208 severity,
209 path: Some(self.path.clone()),
210 concept: Some(self.id.clone()),
211 message: message.into(),
212 fixable,
213 });
214 }
215
216 fn push(&mut self, severity: Severity, message: impl Into<String>) {
217 self.push_fixable(severity, message, false);
218 }
219
220 fn error(&mut self, message: impl Into<String>) {
221 self.push(Severity::Error, message);
222 }
223
224 fn warn(&mut self, message: impl Into<String>) {
225 self.push(Severity::Warning, message);
226 }
227
228 fn warn_fixable(&mut self, message: impl Into<String>) {
229 self.push_fixable(Severity::Warning, message, true);
230 }
231
232 fn info(&mut self, message: impl Into<String>) {
233 self.push(Severity::Info, message);
234 }
235}
236
237impl Report {
238 fn add_fixable(
239 &mut self,
240 severity: Severity,
241 path: Option<PathBuf>,
242 concept: Option<ConceptId>,
243 message: String,
244 fixable: bool,
245 ) {
246 self.diagnostics.push(Diagnostic {
247 severity,
248 path,
249 concept,
250 message,
251 fixable,
252 });
253 }
254
255 fn add(
256 &mut self,
257 severity: Severity,
258 path: Option<PathBuf>,
259 concept: Option<ConceptId>,
260 message: String,
261 ) {
262 self.add_fixable(severity, path, concept, message, false);
263 }
264
265 fn error(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
266 self.add(Severity::Error, path, concept, message);
267 }
268
269 fn warn(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
270 self.add(Severity::Warning, path, concept, message);
271 }
272
273 fn info(&mut self, path: Option<PathBuf>, concept: Option<ConceptId>, message: String) {
274 self.add(Severity::Info, path, concept, message);
275 }
276}
277
278fn check_recommended(cx: &mut Context, doc: &Document) {
283 for field in doc.missing_recommended() {
284 if field == "runtime" {
287 continue;
288 }
289 let fixable = field == "title" || field == "generated";
290 cx.push_fixable(
291 Severity::Warning,
292 format!("missing recommended frontmatter field `{field}`"),
293 fixable,
294 );
295 }
296}
297
298fn check_tags(cx: &mut Context, fm: &Frontmatter) {
305 let Some(value) = fm.get("tags").filter(|v| !v.is_empty_value()) else {
306 return;
307 };
308 if !matches!(value, Value::Sequence(_)) {
309 cx.warn(format!(
310 "`tags` should be a list of short strings, found {}; no tags are read from it",
311 type_name(value)
312 ));
313 }
314}
315
316fn check_trust(cx: &mut Context, fm: &Frontmatter) {
318 if let Some(value) = fm.get("generated").filter(|v| !v.is_empty_value()) {
319 match fm.generated() {
320 None => cx.warn(format!(
321 "`generated` should be a `{{ by, at }}` mapping, found {}",
322 type_name(value)
323 )),
324 Some(generated) => {
325 if generated.by.is_none() {
326 cx.warn("`generated.by` is required within `generated`");
327 }
328 if let Some(at) = generated.at.filter(|a| !a.is_valid()) {
329 cx.warn(format!(
330 "`generated.at` is not an ISO-8601 datetime with an explicit offset: {:?}",
331 at.raw
332 ));
333 }
334 }
335 }
336 }
337
338 let Some(value) = fm.get("verified").filter(|v| !v.is_empty_value()) else {
339 return;
340 };
341 if !matches!(value, Value::Sequence(_) | Value::Mapping(_)) {
342 cx.warn(format!(
343 "`verified` should be a list of `{{ by, at }}` events (a bare mapping is read as \
344 a one-element list), found {}",
345 type_name(value)
346 ));
347 return;
348 }
349 let events = fm.verified();
350 if events.is_empty() {
351 cx.warn("`verified` contains no `{ by, at }` events");
352 }
353 match value {
354 Value::Sequence(items) => {
355 for (i, item) in items.iter().enumerate() {
356 let Some(event) = Verification::from_value(item) else {
357 cx.warn(format!(
358 "`verified[{i}]` should be a mapping with `by` and `at`, found {}",
359 type_name(item)
360 ));
361 continue;
362 };
363 check_verification_event(cx, i, &event);
364 }
365 }
366 Value::Mapping(_) => {
367 if let Some(event) = Verification::from_value(value) {
368 check_verification_event(cx, 0, &event);
369 }
370 }
371 _ => unreachable!("verified shape checked above"),
372 }
373}
374
375fn check_verification_event(cx: &mut Context, i: usize, event: &Verification) {
376 if event
377 .by
378 .as_ref()
379 .is_none_or(|by| by.as_str().trim().is_empty())
380 {
381 cx.warn(format!("`verified[{i}].by` is missing"));
382 }
383 match &event.at {
384 None => cx.warn(format!("`verified[{i}].at` is missing")),
385 Some(at) if !at.is_valid() => cx.warn(format!(
386 "`verified[{i}].at` is not an ISO-8601 datetime with an explicit offset: {:?}",
387 at.raw
388 )),
389 Some(_) => {}
390 }
391}
392
393fn check_lifecycle(cx: &mut Context, fm: &Frontmatter, today: Option<Date>) {
395 let status = fm.status();
396 if !status.is_known() {
397 cx.warn(format!(
398 "unknown `status` value {:?}; the spec defines {} (consumers must still accept it)",
399 status.to_string(),
400 STATUS_VALUES.join(", ")
401 ));
402 }
403
404 let Some(stale_after) = fm.stale_after() else {
405 return;
406 };
407 match &stale_after.datetime {
408 Some(dt) if stale_after.is_valid() => {
409 if let Some(today) = today
410 && today.to_utc_datetime() >= *dt
411 {
412 cx.info(format!("stale since {stale_after}"));
413 }
414 }
415 _ => {
416 cx.warn(format!(
417 "`stale_after` is not an ISO-8601 datetime with an explicit offset: {:?}",
418 stale_after.raw
419 ));
420 }
421 }
422}
423
424fn check_provenance(cx: &mut Context, fm: &Frontmatter) {
426 let Some(value) = fm.get("sources").filter(|v| !v.is_empty_value()) else {
427 if fm.get("usage_window").is_some() {
429 cx.warn("`usage_window` is present without `sources` to frame");
430 }
431 return;
432 };
433 if !matches!(value, Value::Sequence(_) | Value::Mapping(_)) {
434 cx.warn(format!(
435 "`sources` should be a list of entries, found {}",
436 type_name(value)
437 ));
438 return;
439 }
440
441 let shared_window = fm.usage_window();
442 if let Some(window) = &shared_window {
443 for (field, date) in [("from", &window.from), ("to", &window.to)] {
444 if let Some(d) = date.as_ref().filter(|d| !d.is_valid()) {
445 cx.warn(format!(
446 "`usage_window.{field}` is not an ISO-8601 datetime with an explicit offset: {:?}",
447 d.raw
448 ));
449 }
450 }
451 }
452
453 let mut seen_ids: HashSet<String> = HashSet::new();
454 let entries: Vec<(usize, Source)> = match value {
455 Value::Sequence(items) => items
456 .iter()
457 .enumerate()
458 .filter_map(|(i, item)| {
459 if item.as_mapping().is_none() {
460 cx.warn(format!(
461 "`sources[{i}]` should be a mapping entry, found {}",
462 type_name(item)
463 ));
464 None
465 } else {
466 Source::from_value(item).map(|source| (i, source))
467 }
468 })
469 .collect(),
470 Value::Mapping(_) => Source::from_value(value)
471 .into_iter()
472 .map(|source| (0, source))
473 .collect(),
474 _ => unreachable!("sources shape checked above"),
475 };
476 for (i, source) in &entries {
477 if source.resource_kind() == ResourceKind::Missing {
478 cx.warn(format!(
479 "`sources[{i}].resource` is required within an entry"
480 ));
481 }
482 if let Some(id) = &source.id
483 && !seen_ids.insert(id.clone())
484 {
485 cx.warn(format!(
486 "duplicate `sources[].id` {id:?}; ids are the join key for attribution"
487 ));
488 }
489 if let Some(last_modified) = source.last_modified.as_ref().filter(|d| !d.is_valid()) {
490 cx.warn(format!(
491 "`sources[{i}].last_modified` is not an ISO-8601 datetime with an explicit offset: {:?}",
492 last_modified.raw
493 ));
494 }
495 if source.usage_count.is_some()
496 && source
497 .effective_usage_window(shared_window.as_ref())
498 .is_none()
499 {
500 cx.warn(format!(
501 "`sources[{i}].usage_count` has no `usage_window` to frame it"
502 ));
503 }
504 }
505
506 if let Value::Sequence(items) = value {
509 for (i, item) in items.iter().enumerate() {
510 let raw = item.as_mapping().and_then(|m| m.get("usage_count"));
511 if let Some(raw) = raw.filter(|v| v.as_int().is_none()) {
512 cx.warn(format!(
513 "`sources[{i}].usage_count` should be an integer, found {}",
514 type_name(raw)
515 ));
516 }
517 }
518 }
519}
520
521fn check_attribution(cx: &mut Context, doc: &Document) {
523 let has_sources = !doc.frontmatter.sources().is_empty();
524 for attribution in doc.attributions() {
525 if !attribution.is_resolved() && has_sources {
526 cx.warn(format!(
527 "footnote [^{}] matches no `sources[].id`; the label is the join key for \
528 attribution",
529 attribution.label
530 ));
531 }
532 if attribution.references > 0 && attribution.definitions == 0 {
533 cx.warn(format!(
534 "footnote [^{}] is cited but never defined",
535 attribution.label
536 ));
537 }
538 }
539}
540
541fn check_legacy(cx: &mut Context, doc: &Document) {
543 let fm = &doc.frontmatter;
544 if !is_blank(fm, "timestamp") {
545 if is_blank(fm, "generated") {
546 cx.warn_fixable("`timestamp` is superseded by `generated: { by, at }`");
547 } else {
548 cx.warn_fixable("`timestamp` is redundant alongside `generated` and should be removed");
549 }
550 }
551 if doc.has_legacy_citations() {
552 cx.warn_fixable(
553 "the body `# Citations` list is superseded by the `sources` frontmatter field",
554 );
555 }
556}
557
558fn check_computation(cx: &mut Context, doc: &Document) {
560 let fm = &doc.frontmatter;
561 let computation_keys = [
562 "runtime",
563 "parameters",
564 "computation",
565 "executor",
566 "attester",
567 ];
568
569 if !fm.is_attested_computation() {
570 let present: Vec<&str> = computation_keys
571 .iter()
572 .copied()
573 .filter(|k| !is_blank(fm, k))
574 .collect();
575 if !present.is_empty() {
576 cx.info(format!(
577 "carries computation field(s) `{}` but `type` is not `{ATTESTED_COMPUTATION_TYPE}`; \
578 a sanctioned computation is its own concept",
579 present.join("`, `")
580 ));
581 }
582 return;
583 }
584
585 let Some(contract) = doc.attested_computation() else {
586 return;
587 };
588
589 if contract.runtime.is_none() {
590 cx.warn(
591 "`runtime` is required on an `Attested Computation`; it defines what `parameters` mean",
592 );
593 }
594 match &contract.computation {
595 ComputationSource::Missing => cx.warn(
596 "no computation: set `computation` to a path or add a `# Computation` block to the body",
597 ),
598 ComputationSource::File(_) if contract.has_redundant_inline => cx.warn(
599 "`computation` names a file and the body also has a `# Computation` block; the spec asks for one or the other",
600 ),
601 _ => {}
602 }
603
604 for (i, parameter) in contract.parameters.iter().enumerate() {
605 if parameter.name.is_none() {
606 cx.warn(format!("`parameters[{i}].name` is missing"));
607 }
608 if parameter.type_.is_none() {
609 cx.warn(format!("`parameters[{i}].type` is missing"));
610 }
611 }
612
613 match &contract.executor {
614 None => cx.warn("missing `executor`: nothing says how to run the computation"),
615 Some(executor) => {
616 if executor.resource.is_none() {
617 cx.warn("`executor.resource` is missing; it names the run instructions or code");
618 }
619 if executor.receipt.is_empty() {
620 cx.warn(
621 "`executor.receipt` is empty; it declares the evidence the attester inspects",
622 );
623 }
624 }
625 }
626
627 match &contract.attester {
628 None => cx.warn("missing `attester`: nothing can check a run's receipt"),
629 Some(attester) if attester.resource.is_none() => {
630 cx.warn("`attester.resource` is missing; it names the deterministic check");
631 }
632 Some(_) => {}
633 }
634}
635
636fn check_path_fields(cx: &mut Context, bundle: &Bundle, fm: &Frontmatter) {
645 let id = cx.id.clone();
646 for (field, raw) in fm.path_fields() {
647 let target = raw.trim();
648 let explicit_path =
649 target.starts_with('/') || target.starts_with("./") || target.starts_with("../");
650 if field == "resource" && !explicit_path {
651 continue;
652 }
653 if okf_core::links::field_path_candidates(target, &id).is_empty() {
654 continue; }
656 if bundle.resolve_path_field(&id, target).is_none() {
657 cx.info(format!(
658 "`{field}` does not resolve to a file in the bundle: {raw}"
659 ));
660 }
661 }
662}
663
664fn check_segment_portability(bundle: &Bundle, report: &mut Report) {
677 let mut seen: HashSet<&str> = HashSet::new();
678 for concept in bundle.concepts() {
679 for segment in concept.id.segments() {
680 if okf_core::concept_id::is_portable_segment(segment) || !seen.insert(segment) {
681 continue;
682 }
683 report.warn(
684 Some(concept.path.clone()),
685 Some(concept.id.clone()),
686 format!(
687 "concept-id segment {segment:?} is outside the conventional \
688 `[A-Za-z0-9_][A-Za-z0-9_.-]*` set; the bundle is still conformant, but \
689 such a name needs `<...>` or percent-encoding to link portably and is \
690 not guaranteed to survive every filesystem unchanged"
691 ),
692 );
693 }
694 }
695}
696
697fn validate_reserved(bundle: &Bundle, report: &mut Report) {
698 let root_index = bundle.root().join("index.md");
699
700 for path in bundle.index_files() {
701 let text = match fs::read_to_string(path) {
702 Ok(text) => text,
703 Err(error) => {
704 report.error(
705 Some(path.clone()),
706 None,
707 format!("unreadable reserved index.md: {error}"),
708 );
709 continue;
710 }
711 };
712 let doc = match Document::parse(&text) {
713 Ok(doc) => doc,
714 Err(error) => {
715 report.error(
716 Some(path.clone()),
717 None,
718 format!("unparseable reserved index.md: {error}"),
719 );
720 continue;
721 }
722 };
723 if doc.frontmatter.is_empty() {
724 continue;
725 }
726 let is_root = path == &root_index;
729 if is_root {
730 let only_version = doc
731 .frontmatter
732 .as_mapping()
733 .keys()
734 .all(|k| k == "okf_version");
735 if !only_version {
736 report.error(
737 Some(path.clone()),
738 None,
739 "root index.md frontmatter should declare only `okf_version`".to_string(),
740 );
741 }
742 } else {
743 report.error(
744 Some(path.clone()),
745 None,
746 "index.md should not contain frontmatter".to_string(),
747 );
748 }
749 }
750
751 for path in bundle.log_files() {
752 let text = match fs::read_to_string(path) {
753 Ok(text) => text,
754 Err(error) => {
755 report.error(
756 Some(path.clone()),
757 None,
758 format!("unreadable reserved log.md: {error}"),
759 );
760 continue;
761 }
762 };
763 let log = Log::parse(&text);
764 for issue in log.structural_errors(&text) {
765 report.error(Some(path.clone()), None, issue);
766 }
767 for bad in log.invalid_dates() {
768 report.error(
769 Some(path.clone()),
770 None,
771 format!("log date heading is not ISO-8601 `YYYY-MM-DD`: {bad:?}"),
772 );
773 }
774 }
775}
776
777fn check_declared_version(bundle: &Bundle, report: &mut Report) {
783 let Some(declared) = bundle.okf_version() else {
784 return;
785 };
786 let declared = declared.trim();
787 if declared == okf_core::OKF_VERSION {
788 return;
789 }
790 let message = if okf_core::SUPPORTED_OKF_VERSIONS.contains(&declared) {
791 format!(
792 "bundle targets OKF v{declared}; read as v{} under documented fallbacks",
793 okf_core::OKF_VERSION
794 )
795 } else {
796 format!(
797 "bundle declares an unrecognized `okf_version: {declared}`; consuming it \
798 best-effort as v{}",
799 okf_core::OKF_VERSION
800 )
801 };
802 report.info(Some(bundle.root().join("index.md")), None, message);
803}
804
805fn is_blank(fm: &Frontmatter, key: &str) -> bool {
806 fm.get(key).is_none_or(Value::is_empty_value)
807}
808
809const fn type_name(value: &Value) -> &'static str {
811 match value {
812 Value::Null => "null",
813 Value::Bool(_) => "a boolean",
814 Value::Int(_) => "an integer",
815 Value::Float(_) => "a float",
816 Value::String(_) => "a string",
817 Value::Sequence(_) => "a list",
818 Value::Mapping(_) => "a mapping",
819 }
820}
821
822#[must_use]
826pub fn is_iso8601_datetime(s: &str) -> bool {
827 DateTime::parse(s)
828 .is_some_and(|datetime| datetime.has_time && datetime.offset_minutes.is_some())
829}