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