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