rto_render/okf/inspect.rs
1//! Inspect an OKF bundle **as a bundle**, without importing it.
2//!
3//! [`read`](super::read) answers "what would this add to the graph". This module
4//! answers questions about the bundle itself — what it claims, whether it hangs
5//! together, how it differs from another copy — and answers them with somebody
6//! else's implementation of the specification.
7//!
8//! # Why an independent implementation is the whole value
9//!
10//! Roteiro both *writes* OKF (`render okf`) and *reads* it (`import --from
11//! okf`). A reader of our own construction, run over our own output, would
12//! agree with us about a format we also invent: it can only catch a mistake we
13//! did not make twice. `okf-core` is an independent reading of the same
14//! specification by an author who is not us, so its disagreement is
15//! *information*.
16//!
17//! That is not hypothetical here. ADR-0021 records that deriving a concept's
18//! path from its node key "guessed wrong for 43 links" in a real render, and the
19//! reader's own YAML subset silently dropped every human sign-off in Google's
20//! published bundles until an independent oracle was pointed at it. Both were
21//! found by checking our output against something that did not share our
22//! assumptions.
23//!
24//! # What is here, and what is not
25//!
26//! [`trust_summary`], [`link_report`] and [`diff_report`], all built on
27//! `okf-core` — **one crate, zero transitive dependencies**.
28//!
29//! Conformance checking and hygiene linting are **not** here. They live
30//! upstream in a second crate, `okf-validator`, whose dependencies are not
31//! optional and which syntax-checks fenced code blocks in eight languages.
32//! Taking it means taking `rustpython-parser`: 61 crates, `LGPL-3.0-only`
33//! through the `malachite` tree, and six unmaintained advisories whose own text
34//! says no safe upgrade exists. `cargo deny` refuses it on both counts, and
35//! ADR-0017 §3 is explicit that a licence is not admitted merely to turn CI
36//! green.
37//!
38//! That price bought two of the validator's thirty-four checks, both of them
39//! about whether embedded *code* parses rather than whether the *bundle*
40//! conforms. See `Cargo.toml` for the full measurement.
41//!
42//! # Subcommand names are upstream's
43//!
44//! `trust`, `links` and `diff` match the `okf` CLI's own names for the same
45//! operations, so somebody who knows that tool already knows this one. The
46//! library is called **in-process**; Roteiro is a self-contained offline binary
47//! and requiring `okf` on `PATH` would reintroduce exactly the coupling the
48//! vendored interop fixtures exist to avoid.
49
50use std::path::Path;
51
52use okf_core::{Bundle, TrustTier};
53use serde::Serialize;
54
55/// Why a bundle could not be inspected.
56///
57/// One variant today: every failure here is "the path is not a bundle we could
58/// load". The underlying [`okf_core::BundleError`] is rendered into the message
59/// rather than wrapped, so this type stays free of the dependency in its public
60/// shape.
61///
62/// `#[non_exhaustive]` because that set is closed by nothing but current
63/// implementation — unlike [`super::Actor`], whose three variants are closed by
64/// §7 of the specification and which is deliberately exhaustive for that reason.
65/// A second failure mode here (a bundle that loads but declares an OKF version
66/// this crate cannot read, say) is an ordinary addition, and these crates are
67/// published, so it must not be a breaking change.
68#[derive(Debug, thiserror::Error)]
69#[non_exhaustive]
70pub enum InspectError {
71 /// The path could not be loaded as an OKF bundle.
72 #[error("`{path}` is not a readable OKF bundle: {detail}")]
73 Unreadable {
74 /// The path as the caller gave it.
75 path: String,
76 /// What `okf-core` said went wrong.
77 detail: String,
78 },
79 /// `--today` was given a value that is not an ISO `YYYY-MM-DD` date.
80 ///
81 /// Refused rather than silently falling back to the real clock: the flag
82 /// exists so a run is reproducible, and a typo that quietly restored
83 /// today's date would make a green pipeline mean nothing.
84 #[error("`{given}` is not an ISO date (expected YYYY-MM-DD)")]
85 BadDate {
86 /// The value as the caller gave it.
87 given: String,
88 },
89 /// The host clock could not be read and no `--today` was given.
90 #[error("cannot read the current date; pass --today YYYY-MM-DD")]
91 NoClock,
92}
93
94/// Load a bundle, naming the path in the error rather than only the cause.
95pub(super) fn load(root: &Path) -> Result<Bundle, InspectError> {
96 Bundle::load(root).map_err(|e| InspectError::Unreadable {
97 path: root.display().to_string(),
98 detail: e.to_string(),
99 })
100}
101
102/// A concept's trust claim, as the bundle states it.
103#[derive(Debug, Clone, Serialize)]
104pub struct ConceptTrust {
105 /// The concept's path within the bundle, minus `.md`.
106 pub id: String,
107 /// §5.3's tier: `human-reviewed`, `machine-confirmed` or `unverified`.
108 pub tier: &'static str,
109 /// The lifecycle `status` §5.4 resolves for this concept.
110 pub status: String,
111 /// Every actor named in `verified`, in the order the document wrote them.
112 ///
113 /// Present even when the tier is `unverified`: an event with an unparseable
114 /// timestamp does not count toward the tier but is still an attribution the
115 /// bundle made, and dropping it would hide *why* the tier came out low.
116 pub verified_by: Vec<String>,
117 /// The `stale_after` timestamp exactly as the document wrote it, if any.
118 pub stale_after: Option<String>,
119 /// Whether `today >= stale_after` (§5.4).
120 ///
121 /// Independent of `tier`: a concept can be human-reviewed *and* stale, and
122 /// that combination is the one most worth seeing before an import, because
123 /// the tier alone reads as reassurance.
124 pub stale: bool,
125}
126
127/// What a bundle claims about its own trustworthiness.
128///
129/// This is the answer to "should I trust this bundle", stated per concept and in
130/// aggregate, and it is deliberately a **plain data type over a path**: it is
131/// exactly the information a consent prompt wants at the moment it asks, and
132/// nothing here needs the import machinery to have run first.
133#[derive(Debug, Clone, Serialize)]
134pub struct TrustSummary {
135 /// The bundle root, as the caller named it.
136 pub root: String,
137 /// The `okf_version` the root `index.md` declares (§10), if any.
138 pub okf_version: Option<String>,
139 /// Concepts read, excluding the reserved `index.md` / `log.md` files.
140 pub total: usize,
141 /// Concepts carrying at least one valid `human:` verifier.
142 pub human_reviewed: usize,
143 /// Concepts verified only by non-`human:` actors.
144 pub machine_confirmed: usize,
145 /// Concepts with no valid `verified` event.
146 pub unverified: usize,
147 /// Concepts whose `stale_after` has passed, as of `today`.
148 pub stale: usize,
149 /// The date staleness was judged against, as `YYYY-MM-DD`.
150 ///
151 /// Always reported, whether it came from `--today` or the host clock, so a
152 /// captured summary says what it was true *of*. A tiered count with no date
153 /// beside it cannot be compared with the same bundle read a month later.
154 pub today: String,
155 /// Every concept, in bundle order.
156 pub concepts: Vec<ConceptTrust>,
157}
158
159/// Derive [`TrustSummary`] for the bundle at `root`.
160///
161/// # Errors
162///
163/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle,
164/// [`InspectError::BadDate`] if `today` is given and is not an ISO date, and
165/// [`InspectError::NoClock`] if `today` is `None` and the host date cannot be
166/// read. The last is the one worth handling deliberately: it is the only way
167/// this fails on a perfectly good bundle, and the remedy is to pass `today`.
168pub fn trust_summary(root: &Path, today: Option<&str>) -> Result<TrustSummary, InspectError> {
169 let today = resolve_today(today)?;
170 Ok(summarise_trust(
171 &load(root)?,
172 &root.display().to_string(),
173 today,
174 ))
175}
176
177/// The date staleness is judged against: `--today` when given, else the host's
178/// UTC date.
179///
180/// Separated out because it is the only non-deterministic input in this module,
181/// and every report that mentions staleness takes it the same way.
182fn resolve_today(given: Option<&str>) -> Result<okf_core::Date, InspectError> {
183 match given {
184 Some(raw) => okf_core::Date::parse(raw).ok_or_else(|| InspectError::BadDate {
185 given: raw.to_owned(),
186 }),
187 None => okf_core::Date::today_utc().ok_or(InspectError::NoClock),
188 }
189}
190
191/// The bundle-in-hand half of [`trust_summary`].
192///
193/// Split out so a caller that has already loaded a [`Bundle`] — to validate it,
194/// or to ask a person whether to import it — pays for the directory walk once.
195#[must_use]
196pub fn summarise_trust(bundle: &Bundle, root: &str, today: okf_core::Date) -> TrustSummary {
197 let mut summary = TrustSummary {
198 root: root.to_owned(),
199 okf_version: bundle.okf_version().map(ToOwned::to_owned),
200 total: bundle.concepts().len(),
201 human_reviewed: 0,
202 machine_confirmed: 0,
203 unverified: 0,
204 stale: 0,
205 today: today.to_string(),
206 concepts: Vec::with_capacity(bundle.concepts().len()),
207 };
208 for concept in bundle.concepts() {
209 let tier = concept.trust_tier();
210 match tier {
211 TrustTier::HumanReviewed => summary.human_reviewed += 1,
212 TrustTier::MachineConfirmed => summary.machine_confirmed += 1,
213 TrustTier::Unverified => summary.unverified += 1,
214 }
215 let stale = concept.is_stale_on(today);
216 if stale {
217 summary.stale += 1;
218 }
219 summary.concepts.push(ConceptTrust {
220 id: concept.id.to_string(),
221 tier: tier.as_str(),
222 status: concept.status().to_string(),
223 stale_after: concept
224 .document
225 .frontmatter
226 .stale_after()
227 .map(|d| d.to_string()),
228 stale,
229 verified_by: concept
230 .document
231 .frontmatter
232 .verified()
233 .into_iter()
234 .filter_map(|v| v.by.map(|by| by.as_str().to_owned()))
235 .collect(),
236 });
237 }
238 summary
239}
240
241/// A markdown link whose target the bundle does not contain **at all**.
242///
243/// Not "names no concept", which is what this meant before issue #778 and is a
244/// weaker claim: a link to a diagram sitting in the bundle names no concept and
245/// is not thereby broken. A target that is present but is not a concept is a
246/// [`NonConceptLink`].
247#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
248pub struct BrokenLink {
249 /// The concept whose body carries the link.
250 pub from: String,
251 /// The link target, exactly as written.
252 pub target: String,
253}
254
255/// A link whose target the bundle contains, but not as a concept.
256///
257/// An asset — a diagram, a dashboard definition — or one of the reserved
258/// `index.md` / `log.md`, which are bundle files with defined meaning that prose
259/// is expected to point at.
260#[derive(Debug, Clone, Serialize)]
261pub struct NonConceptLink {
262 /// The concept whose body carries the link.
263 pub from: String,
264 /// The link target, exactly as written.
265 pub target: String,
266 /// Where it resolved, relative to the bundle root — the evidence that this
267 /// is not a dead link.
268 pub path: String,
269}
270
271/// Whether an emitted bundle's internal links resolve.
272///
273/// Roteiro's own link checking (`roteiro check`) covers the **graph** and the
274/// **rendered site**. Neither looks at an emitted OKF bundle, which is a third
275/// artefact produced by a third code path — the one ADR-0021 records guessing
276/// wrong for 43 links.
277#[derive(Debug, Clone, Serialize)]
278pub struct LinkReport {
279 /// The bundle root, as the caller named it.
280 pub root: String,
281 /// Concepts read.
282 pub concepts: usize,
283 /// Internal concept links found across every body.
284 pub links: usize,
285 /// Targets the bundle does not contain **at all** — dead links, and the only
286 /// thing `--check` gates on.
287 pub broken: Vec<BrokenLink>,
288 /// Targets the bundle *does* contain, but which are not concepts: an asset
289 /// such as a diagram or a data file, or one of the reserved `index.md` /
290 /// `log.md`. Reported, never gated — see [`LinkReport::is_clean`].
291 pub non_concept: Vec<NonConceptLink>,
292}
293
294impl LinkReport {
295 /// `true` when every internal link names something the bundle contains.
296 ///
297 /// **Not "every link resolves to a concept"**, which is what this used to
298 /// mean and was the defect in issue #778. A bundle of any size links to its
299 /// own diagrams and data files; those resolve to no *concept*, so gating on
300 /// that made `--check` fail on every such bundle. On one real bundle it
301 /// reported 17 broken links of which all 17 existed on disk, and the single
302 /// genuinely dead link was indistinguishable in the output — which is the
303 /// failure worth avoiding, because a gate that cries wolf gets switched off
304 /// and takes the real dead link with it.
305 ///
306 /// So a present-but-not-a-concept target is reported in
307 /// [`LinkReport::non_concept`] and does not fail the gate, which also lines
308 /// this command up with `okf info` (which already lists those files as
309 /// bundle contents) and `okf validate` (which already rates them *info*).
310 #[must_use]
311 pub const fn is_clean(&self) -> bool {
312 self.broken.is_empty()
313 }
314}
315
316/// Resolve every internal link in the bundle at `root`.
317///
318/// # Errors
319///
320/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
321pub fn link_report(root: &Path) -> Result<LinkReport, InspectError> {
322 let bundle = load(root)?;
323 let links = bundle
324 .concepts()
325 .iter()
326 .map(|c| bundle.links_from(&c.id).len())
327 .sum();
328 // okf-core's `exists` means "resolves to a concept", which is the right
329 // question for *it* and the wrong one for a gate: a link to a diagram beside
330 // the concept resolves to no concept and is not thereby dead. Split the two
331 // apart by asking the bundle where the raw target actually lands.
332 //
333 // `resolve_path_field` is okf-core's own resolver for frontmatter fields that
334 // "routinely point at non-markdown files", which is exactly this shape. Reused
335 // rather than reimplemented because the edge cases live in the resolution:
336 // it strips anchors, tries both the concept-relative and root-relative
337 // readings the spec uses, and — the part worth not rewriting — returns nothing
338 // when `..` walks off the top of the bundle, so a link cannot claim a file
339 // outside the bundle as evidence that it resolves.
340 let mut broken = Vec::new();
341 let mut non_concept = Vec::new();
342 for (from, target) in bundle.broken_links() {
343 match bundle.resolve_path_field(&from, &target) {
344 Some(found) => non_concept.push(NonConceptLink {
345 from: from.to_string(),
346 target,
347 // Stripped against **`bundle.root()`**, not the `root` this
348 // function was handed. They are usually the same and need not
349 // be: a library caller may pass `.` where the bundle stored an
350 // absolute path, and then the strip would silently fail and the
351 // field would contradict its own documentation. `bundle.root()`
352 // is by construction the prefix `resolve_path_field` joined, so
353 // this is right by the same reasoning the path was built with.
354 // Raised in review of #778.
355 path: found
356 .strip_prefix(bundle.root())
357 .unwrap_or(&found)
358 .display()
359 .to_string(),
360 }),
361 None => broken.push(BrokenLink {
362 from: from.to_string(),
363 target,
364 }),
365 }
366 }
367
368 Ok(LinkReport {
369 root: root.display().to_string(),
370 concepts: bundle.concepts().len(),
371 links,
372 broken,
373 non_concept,
374 })
375}
376
377/// A concept whose trust tier or lifecycle status moved between two bundles.
378#[derive(Debug, Clone, Serialize)]
379pub struct TrustMove {
380 /// The concept that moved.
381 pub id: String,
382 /// `(before, after)` tiers, when the tier changed.
383 pub tier: Option<(String, String)>,
384 /// `(before, after)` statuses, when the status changed.
385 pub status: Option<(String, String)>,
386}
387
388/// What changed between two bundles, semantically rather than by bytes.
389///
390/// ADR-0021 made `render okf` byte-deterministic specifically so "a consumer can
391/// diff two downloads and learn something". This is that diff, and it is the
392/// first thing in the workspace to exercise the determinism: `review --base`
393/// diffs code, not bundles.
394///
395/// A **rename** is the interesting field. A textual diff of two bundles reports
396/// a moved concept as one deletion and one unrelated addition; this reports it
397/// as a rename, which is the difference between "we lost a concept" and "we
398/// moved one".
399#[derive(Debug, Clone, Serialize)]
400pub struct DiffReport {
401 /// The bundle taken as "before".
402 pub before: String,
403 /// The bundle taken as "after".
404 pub after: String,
405 /// Concepts present only in `after`.
406 pub added: Vec<String>,
407 /// Concepts present only in `before`.
408 pub removed: Vec<String>,
409 /// Concepts whose path changed, as `(from, to)`.
410 pub renamed: Vec<(String, String)>,
411 /// Concepts whose body changed.
412 pub content_changed: Vec<String>,
413 /// Concepts whose frontmatter keys changed.
414 pub frontmatter_changed: Vec<String>,
415 /// Concepts whose tier or status moved. The one to read first.
416 pub trust_changed: Vec<TrustMove>,
417 /// Links that broke between `before` and `after`, as `(concept, target)`.
418 pub links_broken: Vec<(String, String)>,
419 /// Links that were broken in `before` and resolve in `after`.
420 pub links_mended: Vec<(String, String)>,
421}
422
423impl DiffReport {
424 /// `true` when the two bundles are semantically identical.
425 #[must_use]
426 pub fn is_unchanged(&self) -> bool {
427 self.added.is_empty()
428 && self.removed.is_empty()
429 && self.renamed.is_empty()
430 && self.content_changed.is_empty()
431 && self.frontmatter_changed.is_empty()
432 && self.trust_changed.is_empty()
433 && self.links_broken.is_empty()
434 && self.links_mended.is_empty()
435 }
436}
437
438/// Compare two bundles semantically.
439///
440/// # Errors
441///
442/// [`InspectError::Unreadable`] if either path is not a loadable OKF bundle.
443pub fn diff_report(before: &Path, after: &Path) -> Result<DiffReport, InspectError> {
444 let a = load(before)?;
445 let b = load(after)?;
446 let d = okf_core::bundle_diff(&a, &b);
447 let ids = |v: Vec<okf_core::ConceptId>| v.iter().map(ToString::to_string).collect::<Vec<_>>();
448 let pairs = |v: Vec<(okf_core::ConceptId, String)>| {
449 v.into_iter()
450 .map(|(id, t)| (id.to_string(), t))
451 .collect::<Vec<_>>()
452 };
453 Ok(DiffReport {
454 before: before.display().to_string(),
455 after: after.display().to_string(),
456 added: ids(d.added),
457 removed: ids(d.removed),
458 renamed: d
459 .renamed
460 .into_iter()
461 .map(|r| (r.from.to_string(), r.to.to_string()))
462 .collect(),
463 content_changed: ids(d.content),
464 frontmatter_changed: d.frontmatter.iter().map(|c| c.id.to_string()).collect(),
465 trust_changed: d
466 .trust
467 .into_iter()
468 .map(|t| TrustMove {
469 id: t.id.to_string(),
470 tier: t
471 .tier
472 .map(|(a, b)| (a.as_str().to_owned(), b.as_str().to_owned())),
473 status: t.status.map(|(a, b)| (a.to_string(), b.to_string())),
474 })
475 .collect(),
476 links_broken: pairs(d.broken_links),
477 links_mended: pairs(d.mended_links),
478 })
479}
480
481/// One code block that did not parse.
482#[derive(Debug, Clone, Serialize)]
483pub struct SyntaxFinding {
484 /// The concept the block belongs to.
485 pub concept: String,
486 /// The concept's file, relative to the bundle root.
487 pub path: String,
488 /// 1-indexed line of the block's opening fence within that file's body,
489 /// when it could be determined.
490 ///
491 /// `None` for a computation whose code this crate could not locate in the
492 /// body — an indented block with no `# Computation` heading to anchor it.
493 /// Reporting a confident `1` there was worse than reporting nothing: it sent
494 /// a reader to the frontmatter for a fault further down the file.
495 pub line: Option<usize>,
496 /// The language the block was tagged with, canonicalised.
497 pub language: String,
498 /// What the parser said.
499 pub message: String,
500}
501
502/// The result of syntax-checking a bundle's code blocks.
503///
504/// `checked` and `skipped` are both reported, deliberately. A language with no
505/// backend compiled in is *not checked* rather than *clean*, and a report that
506/// conflated the two would be a check that passes by not looking.
507#[derive(Debug, Clone, Serialize)]
508pub struct SyntaxReport {
509 /// The bundle root, as the caller named it.
510 pub root: String,
511 /// `computations` or `all-blocks` — what was looked at.
512 pub scope: &'static str,
513 /// Blocks a backend actually parsed.
514 pub checked: usize,
515 /// Blocks left alone, for any of three reasons: the block carried no
516 /// language tag, this build has no backend for the language it carried, or
517 /// the computation named a file rather than inlining its code.
518 ///
519 /// All three are "not looked at" rather than "looked at and clean", which is
520 /// the distinction the whole report exists to keep.
521 pub skipped: usize,
522 /// The languages this build can check, so a reader can tell why.
523 pub languages: Vec<String>,
524 /// Findings, in bundle order.
525 pub findings: Vec<SyntaxFinding>,
526}
527
528impl SyntaxReport {
529 /// `true` when nothing failed to parse.
530 #[must_use]
531 pub const fn passed(&self) -> bool {
532 self.findings.is_empty()
533 }
534}
535
536/// The language an untagged computation block should be read as.
537///
538/// Only `bigquery` is mapped, and only because the corpus justifies it: every
539/// `runtime:` in the four bundles published with the specification is
540/// `bigquery`, and the spec's own Attested Computation example writes its query
541/// as an *indented* block, which carries no info string. Without this the one
542/// case that matters most would never be checked.
543///
544/// Deliberately not a general runtime→language table. Inventing a mapping for
545/// runtimes nobody has written yet is how a reader ends up with a confident
546/// diagnostic about a language the author never claimed.
547fn language_for_runtime(runtime: Option<&str>) -> Option<&'static str> {
548 // Case-insensitive, because every other tag here is: `Language::from_tag`
549 // lowercases, so `runtime: BigQuery` reading differently from `bigquery`
550 // would be an inconsistency inside one function's worth of code.
551 match runtime.map(|r| r.trim().to_ascii_lowercase()).as_deref() {
552 Some("bigquery") => Some("sql"),
553 _ => None,
554 }
555}
556
557/// Syntax-check the code blocks in a bundle.
558///
559/// With `computations_only`, just the bodies of Attested Computations — the
560/// concepts that declare a `runtime:` and that an agent is expected to *run*, so
561/// the ones where "does this parse" is a question about the bundle rather than
562/// about its prose. Otherwise every fenced block in every document.
563///
564/// Findings are the checker's, not conformance: a bundle can be perfectly
565/// conformant and contain a code sample that does not parse, which is why this
566/// is its own command rather than part of validation.
567///
568/// # Errors
569///
570/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
571pub fn syntax_report(root: &Path, computations_only: bool) -> Result<SyntaxReport, InspectError> {
572 let bundle = load(root)?;
573 let languages = rto_okf_syntax::checkable_languages()
574 .into_iter()
575 .map(|l| l.as_str().to_owned())
576 .collect();
577 let mut report = SyntaxReport {
578 root: root.display().to_string(),
579 scope: if computations_only {
580 "computations"
581 } else {
582 "all-blocks"
583 },
584 checked: 0,
585 skipped: 0,
586 languages,
587 findings: Vec::new(),
588 };
589
590 for concept in bundle.concepts() {
591 let rel = concept
592 .path
593 .strip_prefix(bundle.root())
594 .unwrap_or(&concept.path)
595 .display()
596 .to_string();
597
598 if computations_only {
599 let Some(computation) = concept.attested_computation() else {
600 continue;
601 };
602 let okf_core::ComputationSource::Inline(inline) = &computation.computation else {
603 // A `computation:` file reference is checked by whatever owns
604 // that file, and a `Missing` one has no code to check at all.
605 // Counted as **skipped** rather than passed over silently: a
606 // bundle whose computations all name files would otherwise
607 // report "0 checked, 0 skipped" and print "nothing to check",
608 // which reads as "there were none" when there were several.
609 report.skipped += 1;
610 continue;
611 };
612 // An indented block carries no info string, so fall back to the
613 // declared runtime — see `language_for_runtime`.
614 let tag = inline
615 .language
616 .as_deref()
617 .or_else(|| language_for_runtime(computation.runtime.as_deref()))
618 .unwrap_or("");
619 let line = computation_line(&concept.document.body, &inline.code);
620 record(
621 &mut report,
622 &concept.id.to_string(),
623 &rel,
624 line,
625 tag,
626 &inline.code,
627 );
628 } else {
629 for block in rto_okf_syntax::extract_fenced_code_blocks(&concept.document.body) {
630 let tag = block.language.as_deref().unwrap_or("");
631 record(
632 &mut report,
633 &concept.id.to_string(),
634 &rel,
635 Some(block.start_line),
636 tag,
637 &block.code,
638 );
639 }
640 }
641 }
642
643 Ok(report)
644}
645
646/// Where a computation's code starts in its document.
647///
648/// The fenced case is exact: the same extractor the all-blocks path uses finds
649/// the block whose contents are the computation's, and reports its opening
650/// fence. The indented case cannot be — `okf-core` dedents the code, so it no
651/// longer matches the file byte for byte — and the `# Computation` heading is the
652/// honest anchor there: it is where a reader should look, even though it is not
653/// where the parser stopped.
654///
655/// `None` rather than a confident `1` when neither is found. Pointing a reader at
656/// the frontmatter for a fault further down the file is worse than admitting the
657/// line is unknown.
658fn computation_line(body: &str, code: &str) -> Option<usize> {
659 let wanted = code.trim();
660 if let Some(block) = rto_okf_syntax::extract_fenced_code_blocks(body)
661 .into_iter()
662 .find(|b| b.code.trim() == wanted)
663 {
664 return Some(block.start_line);
665 }
666 body.lines().enumerate().find_map(|(i, l)| {
667 l.trim_start()
668 .strip_prefix('#')
669 .is_some_and(|rest| rest.trim().eq_ignore_ascii_case("computation"))
670 .then_some(i + 1)
671 })
672}
673
674/// Check one block and fold the outcome into the report.
675fn record(
676 report: &mut SyntaxReport,
677 concept: &str,
678 path: &str,
679 line: Option<usize>,
680 tag: &str,
681 code: &str,
682) {
683 let language = rto_okf_syntax::Language::from_tag(tag);
684 if !rto_okf_syntax::is_checkable(language) {
685 report.skipped += 1;
686 return;
687 }
688 report.checked += 1;
689 if let Err(err) = rto_okf_syntax::check_syntax(tag, code) {
690 report.findings.push(SyntaxFinding {
691 concept: concept.to_owned(),
692 path: path.to_owned(),
693 line,
694 language: err.language.clone(),
695 message: err.to_string(),
696 });
697 }
698}
699
700/// One concept's Attested Computation (§10), as the bundle declares it.
701#[derive(Debug, Clone, Serialize)]
702pub struct ComputationEntry {
703 /// The concept carrying the contract.
704 pub concept: String,
705 /// The bundle-relative file it lives in.
706 pub path: String,
707 /// §10's `runtime`, which decides how everything else is interpreted.
708 ///
709 /// `None` is a conformance error, not an absence — the spec makes it
710 /// REQUIRED — and it is surfaced here rather than skipped so a listing and
711 /// `okf validate` agree about what the bundle contains.
712 pub runtime: Option<String>,
713 /// `inline`, `file` or `missing`.
714 pub source: &'static str,
715 /// The file named by a `computation:` key, when `source` is `file`.
716 pub file: Option<String>,
717 /// The fenced language of an inline block, when it declared one.
718 pub language: Option<String>,
719 /// Lines of code in an inline block.
720 pub lines: Option<usize>,
721 /// The named holes an agent may fill.
722 pub parameters: Vec<String>,
723 /// Whether an executor is declared.
724 pub has_executor: bool,
725 /// Whether an attester is declared.
726 pub has_attester: bool,
727 /// `true` when the concept carries **both** an inline block and a
728 /// `computation:` file key.
729 ///
730 /// The spec asks for one or the other, so the two halves can disagree with
731 /// nothing to arbitrate between them. Listed rather than merely counted
732 /// because the fix is per concept.
733 pub redundant_inline: bool,
734}
735
736/// Every Attested Computation a bundle declares.
737#[derive(Debug, Clone, Serialize)]
738pub struct ComputationReport {
739 /// The bundle root, as the caller named it.
740 pub root: String,
741 /// Concepts read.
742 pub concepts: usize,
743 /// Concepts carrying a computation contract.
744 pub computations: usize,
745 /// Of those, how many carry the code inline.
746 pub inline: usize,
747 /// Of those, how many name a file instead.
748 pub file: usize,
749 /// Of those, how many declare neither — an incomplete contract.
750 pub missing: usize,
751 /// Every distinct `runtime`, sorted.
752 pub runtimes: Vec<String>,
753 /// The contracts themselves, in bundle order.
754 pub entries: Vec<ComputationEntry>,
755}
756
757impl ComputationReport {
758 /// Whether every contract found is complete: a runtime, and code somewhere.
759 ///
760 /// This is what `--check` gates on. A bundle with **no** computations is
761 /// clean by this measure, which is the right answer: §10 is optional, and
762 /// failing a bundle for not using an optional feature would make the gate
763 /// unusable on the three of four published bundles that declare none.
764 #[must_use]
765 pub fn is_clean(&self) -> bool {
766 self.incomplete() == 0
767 }
768
769 /// Contracts that are declared but not usable: no `runtime`, no code, or
770 /// both an inline block and a file with nothing to arbitrate between them.
771 #[must_use]
772 pub fn incomplete(&self) -> usize {
773 self.entries
774 .iter()
775 .filter(|e| e.runtime.is_none() || e.source == "missing" || e.redundant_inline)
776 .count()
777 }
778}
779
780/// List the Attested Computations in the bundle at `root`.
781///
782/// # Errors
783///
784/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
785pub fn computation_report(root: &Path) -> Result<ComputationReport, InspectError> {
786 let bundle = load(root)?;
787 let mut report = ComputationReport {
788 root: root.display().to_string(),
789 concepts: bundle.concepts().len(),
790 computations: 0,
791 inline: 0,
792 file: 0,
793 missing: 0,
794 runtimes: Vec::new(),
795 entries: Vec::new(),
796 };
797 let mut runtimes = std::collections::BTreeSet::new();
798
799 for concept in bundle.concepts() {
800 let Some(computation) = concept.attested_computation() else {
801 continue;
802 };
803 report.computations += 1;
804 if let Some(runtime) = computation.runtime.as_deref() {
805 runtimes.insert(runtime.to_owned());
806 }
807 let (source, file, language, lines) = match &computation.computation {
808 okf_core::ComputationSource::Inline(inline) => {
809 report.inline += 1;
810 (
811 "inline",
812 None,
813 inline.language.clone(),
814 Some(inline.code.lines().count()),
815 )
816 }
817 okf_core::ComputationSource::File(path) => {
818 report.file += 1;
819 ("file", Some(path.clone()), None, None)
820 }
821 okf_core::ComputationSource::Missing => {
822 report.missing += 1;
823 ("missing", None, None, None)
824 }
825 };
826 report.entries.push(ComputationEntry {
827 concept: concept.id.to_string(),
828 path: concept
829 .path
830 .strip_prefix(bundle.root())
831 .unwrap_or(&concept.path)
832 .display()
833 .to_string(),
834 runtime: computation.runtime.clone(),
835 source,
836 file,
837 language,
838 lines,
839 // An unnamed parameter is dropped rather than rendered as a hole:
840 // §10 requires the name, so `okf validate` is what reports its
841 // absence, and repeating it here as an empty slot in a listing would
842 // read as a parameter called "".
843 parameters: computation
844 .parameters
845 .iter()
846 .filter_map(|p| p.name.clone())
847 .collect(),
848 has_executor: computation.executor.is_some(),
849 has_attester: computation.attester.is_some(),
850 redundant_inline: computation.has_redundant_inline,
851 });
852 }
853 report.runtimes = runtimes.into_iter().collect();
854 Ok(report)
855}
856
857/// A file a bundle carries that is not one of its concepts.
858#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
859pub struct BundleFile {
860 /// Bundle-relative path.
861 pub path: String,
862 /// Size in bytes, or `None` when it could not be read.
863 ///
864 /// Distinguished from zero rather than conflated with it, because an empty
865 /// file and an unstattable one are different facts and only one of them is
866 /// reassuring — the same reason the walk reports what it could not open. A
867 /// caller summing sizes treats `None` as contributing nothing; a caller
868 /// printing one says so.
869 pub bytes: Option<u64>,
870 /// Lowercased extension, or `""` when the file has none.
871 pub extension: String,
872}
873
874/// What a bundle carries that is not one of its concepts.
875#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
876pub struct BundleContents {
877 /// Every non-markdown file, ordered by path.
878 pub files: Vec<BundleFile>,
879 /// Everything the walk could not inspect, so the inventory above is
880 /// **incomplete**.
881 ///
882 /// Reported rather than swallowed. An inventory that answers "none" because
883 /// something would not open is the same false reassurance this feature exists
884 /// to remove — a reader would take silence for absence, which is precisely
885 /// what "0 violations" over an unread PDF did.
886 ///
887 /// Three failures land here, not one: a directory that will not list, a
888 /// directory entry that will not yield, and an entry whose type cannot be
889 /// read. The first was the obvious case and the other two are the same defect
890 /// one level in — `entries.flatten()` and a `let Ok(kind) = … else continue`
891 /// each discard an error and leave `is_complete()` saying the walk saw
892 /// everything.
893 ///
894 /// A file whose **size** cannot be read is not here: the file itself was
895 /// seen, named and reported, so the inventory is complete. See
896 /// [`BundleFile::bytes`].
897 pub unreadable: Vec<String>,
898}
899
900impl BundleContents {
901 /// Whether the walk saw everything it tried to.
902 #[must_use]
903 pub fn is_complete(&self) -> bool {
904 self.unreadable.is_empty()
905 }
906}
907
908/// Every file in the bundle that is not markdown.
909///
910/// **Nothing here is opened.** The path, the size and the extension come from
911/// the directory entry and its metadata; the bytes are never read, so this adds
912/// no parser and no attack surface of its own.
913///
914/// It exists because a bundle is **not** markdown, whatever the four published
915/// ones happen to contain: `okf-core`'s `resolve_path_field` resolves a
916/// frontmatter path to any file with `is_file()` and no extension filter, and
917/// §10's `computation:` names a file. So a conformant bundle can cite a document
918/// nobody here can read — and until now every report we produced would call that
919/// bundle clean without mentioning the document existed (ADR-0024).
920///
921/// **What it costs**, because it looks cheaper than it is and has more than one
922/// caller: one walk of this repository's own 9,633-file bundle measures **5.9 ms
923/// warm** (40 ms cold), against the `Bundle::load` of the same bundle at
924/// **1.29 s** — which every caller has already paid before reaching here, since
925/// there is nothing to report about a bundle that did not load. Two walks in a
926/// run is under one percent of what the run already spent, so this is not cached.
927/// If that ratio changes, cache it then and put the new number here.
928///
929/// Symlinked directories are **not** followed: this walks a directory a peer
930/// controls, and `loop -> ..` inside one would otherwise never terminate.
931/// Entries are classified with `file_type()`, which reads the directory entry
932/// rather than the link's target, and a symlink is counted as the file it is.
933///
934/// The walk keeps its own stack rather than recursing. Review raised a deep-tree
935/// stack overflow, and it does **not** reproduce — `PATH_MAX` caps the depth an
936/// attacker can build (509 here) and `Bundle::load` refuses such a tree before
937/// this ever runs, with `File name too long`. It is iterative anyway, because
938/// "bounded by the filesystem's path limit" is a platform accident rather than a
939/// property of this function, and an explicit stack costs nothing to make it one.
940#[must_use]
941pub fn bundle_files(root: &Path) -> BundleContents {
942 let mut out = BundleContents::default();
943 let mut stack = vec![root.to_path_buf()];
944 while let Some(dir) = stack.pop() {
945 let Ok(entries) = std::fs::read_dir(&dir) else {
946 out.unreadable.push(relative(root, &dir));
947 continue;
948 };
949 for entry in entries {
950 // Both of these were `flatten()` and `else continue`, which discard
951 // an error and then let `is_complete()` claim the walk saw
952 // everything — the swallowed-failure defect this type exists to
953 // report, one level further in.
954 let Ok(entry) = entry else {
955 out.unreadable.push(relative(root, &dir));
956 continue;
957 };
958 let path = entry.path();
959 let Ok(kind) = entry.file_type() else {
960 out.unreadable.push(relative(root, &path));
961 continue;
962 };
963 if kind.is_dir() {
964 stack.push(path);
965 continue;
966 }
967 let extension = path
968 .extension()
969 .and_then(|e| e.to_str())
970 .map(str::to_ascii_lowercase)
971 .unwrap_or_default();
972 if extension == "md" {
973 continue;
974 }
975 out.files.push(BundleFile {
976 path: relative(root, &path),
977 bytes: std::fs::symlink_metadata(&path).map(|m| m.len()).ok(),
978 extension,
979 });
980 }
981 }
982 // Sorted so two reads of one bundle, and two bundles with the same contents,
983 // report identically — the same determinism `render okf` guarantees. The
984 // stack alone gives no order at all, since it pops depth-first in whatever
985 // order the filesystem returned each directory.
986 out.files.sort_by(|a, b| a.path.cmp(&b.path));
987 // A directory that failed to yield several entries names itself once per
988 // failure, and the count is not information a reader can act on.
989 out.unreadable.sort();
990 out.unreadable.dedup();
991 out
992}
993
994/// A bundle-relative path, with `/` separators on every platform.
995///
996/// `Path::display()` alone emits `\` on Windows, and this string is compared
997/// against the `/`-separated paths a bundle's own frontmatter and links use — so
998/// on Windows an inventory entry would not match the document that cited it.
999///
1000/// A path that is somehow **not** under `root` is rendered as the bare file name
1001/// rather than falling back to the whole path: the fallback would print an
1002/// absolute path from the host into a report about a peer's bundle, which is a
1003/// small disclosure to make in a message whose subject is what a stranger can
1004/// see.
1005///
1006/// The **root itself** renders as `"."`, never as the empty string. It reaches
1007/// here when the bundle root is the thing that will not list, and `strip_prefix`
1008/// against itself yields an empty path — so the report read `1 entry could not be
1009/// inspected:` followed by a blank line, which is a worse failure than the one
1010/// being reported, in the one message whose whole job is to say what could not be
1011/// seen. `"."` is the spelling `AdrHome::dir` already uses for "the root" here.
1012fn relative(root: &Path, path: &Path) -> String {
1013 let rel = path
1014 .strip_prefix(root)
1015 .unwrap_or_else(|_| Path::new(path.file_name().unwrap_or(std::ffi::OsStr::new("?"))));
1016 let joined = rel
1017 .components()
1018 .map(|c| c.as_os_str().to_string_lossy())
1019 .collect::<Vec<_>>()
1020 .join("/");
1021 // The root itself yields an empty path from `strip_prefix` against itself.
1022 if joined.is_empty() {
1023 ".".to_owned()
1024 } else {
1025 joined
1026 }
1027}
1028
1029/// What a bundle is, in one answer.
1030///
1031/// Composed from the reports the other commands already produce rather than
1032/// re-deriving anything: this is the command you run *first*, on a bundle
1033/// somebody handed you, to decide which of the others is worth running.
1034#[derive(Debug, Clone, Serialize)]
1035pub struct BundleInfo {
1036 /// The bundle root, as the caller named it.
1037 pub root: String,
1038 /// The `okf_version` the root `index.md` declares (§10), if any.
1039 ///
1040 /// Absent is conformant — §8 and §12 make it MAY — so this is reported and
1041 /// never warned about.
1042 pub okf_version: Option<String>,
1043 /// The bundle's title, from `index.md`.
1044 pub title: Option<String>,
1045 /// Concepts, excluding the reserved `index.md` / `log.md`.
1046 pub concepts: usize,
1047 /// Trust tiers and staleness, as of `today`.
1048 pub trust: TrustSummary,
1049 /// How many concepts carry each `status`, sorted by status.
1050 pub statuses: Vec<(String, usize)>,
1051 /// Internal links, and how many resolve to nothing.
1052 pub links: (usize, usize),
1053 /// Attested Computations, and how many are incomplete.
1054 pub computations: (usize, usize),
1055 /// Every distinct computation `runtime`, sorted.
1056 pub runtimes: Vec<String>,
1057 /// Files the bundle carries that are not concepts, and any directory the
1058 /// walk could not list — see [`bundle_files`].
1059 ///
1060 /// Reported whether or not there are any, because "no unscreenable files" is
1061 /// information and a line that appears only sometimes is one a reader learns
1062 /// to stop looking for.
1063 pub files: BundleContents,
1064}
1065
1066/// Summarise the bundle at `root`.
1067///
1068/// # Errors
1069///
1070/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle,
1071/// [`InspectError::BadDate`] if `today` is given and is not an ISO date, and
1072/// [`InspectError::NoClock`] if `today` is `None` and the host date cannot be
1073/// read — the same three as [`trust_summary`], which this calls.
1074pub fn bundle_info(root: &Path, today: Option<&str>) -> Result<BundleInfo, InspectError> {
1075 let today = resolve_today(today)?;
1076 let bundle = load(root)?;
1077 let trust = summarise_trust(&bundle, &root.display().to_string(), today);
1078 let links = link_report(root)?;
1079 let computations = computation_report(root)?;
1080
1081 let mut statuses: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
1082 for concept in bundle.concepts() {
1083 *statuses.entry(concept.status().to_string()).or_default() += 1;
1084 }
1085
1086 Ok(BundleInfo {
1087 root: root.display().to_string(),
1088 okf_version: bundle.okf_version().map(ToOwned::to_owned),
1089 title: bundle_title(&bundle),
1090 concepts: bundle.concepts().len(),
1091 trust,
1092 statuses: statuses.into_iter().collect(),
1093 links: (links.links, links.broken.len()),
1094 computations: (computations.computations, computations.incomplete()),
1095 runtimes: computations.runtimes,
1096 files: bundle_files(root),
1097 })
1098}
1099
1100/// The bundle's own title, from the `title` of its root `index.md`.
1101///
1102/// Read through `Document::parse` — the same parser `Bundle::load` uses — rather
1103/// than a second reader of the same bytes, so the two cannot disagree about what
1104/// the file says. `okf-core` exposes the index only as a path, so this re-reads
1105/// one small file; that is cheap next to the directory walk, and a bundle whose
1106/// index is unreadable simply has no title here, because `okf validate` is what
1107/// reports a broken index.
1108fn bundle_title(bundle: &Bundle) -> Option<String> {
1109 let path = bundle.index_files().first()?;
1110 let text = std::fs::read_to_string(path).ok()?;
1111 let document = okf_core::Document::parse(&text).ok()?;
1112 document
1113 .frontmatter
1114 .title()
1115 .map(std::borrow::Cow::into_owned)
1116}