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