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