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}
80
81/// Load a bundle, naming the path in the error rather than only the cause.
82fn load(root: &Path) -> Result<Bundle, InspectError> {
83 Bundle::load(root).map_err(|e| InspectError::Unreadable {
84 path: root.display().to_string(),
85 detail: e.to_string(),
86 })
87}
88
89/// A concept's trust claim, as the bundle states it.
90#[derive(Debug, Clone, Serialize)]
91pub struct ConceptTrust {
92 /// The concept's path within the bundle, minus `.md`.
93 pub id: String,
94 /// §5.3's tier: `human-reviewed`, `machine-confirmed` or `unverified`.
95 pub tier: &'static str,
96 /// The lifecycle `status` §5.4 resolves for this concept.
97 pub status: String,
98 /// Every actor named in `verified`, in the order the document wrote them.
99 ///
100 /// Present even when the tier is `unverified`: an event with an unparseable
101 /// timestamp does not count toward the tier but is still an attribution the
102 /// bundle made, and dropping it would hide *why* the tier came out low.
103 pub verified_by: Vec<String>,
104}
105
106/// What a bundle claims about its own trustworthiness.
107///
108/// This is the answer to "should I trust this bundle", stated per concept and in
109/// aggregate, and it is deliberately a **plain data type over a path**: it is
110/// exactly the information a consent prompt wants at the moment it asks, and
111/// nothing here needs the import machinery to have run first.
112#[derive(Debug, Clone, Serialize)]
113pub struct TrustSummary {
114 /// The bundle root, as the caller named it.
115 pub root: String,
116 /// The `okf_version` the root `index.md` declares (§10), if any.
117 pub okf_version: Option<String>,
118 /// Concepts read, excluding the reserved `index.md` / `log.md` files.
119 pub total: usize,
120 /// Concepts carrying at least one valid `human:` verifier.
121 pub human_reviewed: usize,
122 /// Concepts verified only by non-`human:` actors.
123 pub machine_confirmed: usize,
124 /// Concepts with no valid `verified` event.
125 pub unverified: usize,
126 /// Every concept, in bundle order.
127 pub concepts: Vec<ConceptTrust>,
128}
129
130/// Derive [`TrustSummary`] for the bundle at `root`.
131///
132/// # Errors
133///
134/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
135pub fn trust_summary(root: &Path) -> Result<TrustSummary, InspectError> {
136 Ok(summarise_trust(&load(root)?, &root.display().to_string()))
137}
138
139/// The bundle-in-hand half of [`trust_summary`].
140///
141/// Split out so a caller that has already loaded a [`Bundle`] — to validate it,
142/// or to ask a person whether to import it — pays for the directory walk once.
143#[must_use]
144pub fn summarise_trust(bundle: &Bundle, root: &str) -> TrustSummary {
145 let mut summary = TrustSummary {
146 root: root.to_owned(),
147 okf_version: bundle.okf_version().map(ToOwned::to_owned),
148 total: bundle.concepts().len(),
149 human_reviewed: 0,
150 machine_confirmed: 0,
151 unverified: 0,
152 concepts: Vec::with_capacity(bundle.concepts().len()),
153 };
154 for concept in bundle.concepts() {
155 let tier = concept.trust_tier();
156 match tier {
157 TrustTier::HumanReviewed => summary.human_reviewed += 1,
158 TrustTier::MachineConfirmed => summary.machine_confirmed += 1,
159 TrustTier::Unverified => summary.unverified += 1,
160 }
161 summary.concepts.push(ConceptTrust {
162 id: concept.id.to_string(),
163 tier: tier.as_str(),
164 status: concept.status().to_string(),
165 verified_by: concept
166 .document
167 .frontmatter
168 .verified()
169 .into_iter()
170 .filter_map(|v| v.by.map(|by| by.as_str().to_owned()))
171 .collect(),
172 });
173 }
174 summary
175}
176
177/// A markdown link that names a concept the bundle does not contain.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179pub struct BrokenLink {
180 /// The concept whose body carries the link.
181 pub from: String,
182 /// The link target, exactly as written.
183 pub target: String,
184}
185
186/// Whether an emitted bundle's internal links resolve.
187///
188/// Roteiro's own link checking (`roteiro check`) covers the **graph** and the
189/// **rendered site**. Neither looks at an emitted OKF bundle, which is a third
190/// artefact produced by a third code path — the one ADR-0021 records guessing
191/// wrong for 43 links.
192#[derive(Debug, Clone, Serialize)]
193pub struct LinkReport {
194 /// The bundle root, as the caller named it.
195 pub root: String,
196 /// Concepts read.
197 pub concepts: usize,
198 /// Internal concept links found across every body.
199 pub links: usize,
200 /// Those that resolve to no concept in the bundle.
201 pub broken: Vec<BrokenLink>,
202}
203
204impl LinkReport {
205 /// `true` when every internal link resolves.
206 #[must_use]
207 pub const fn is_clean(&self) -> bool {
208 self.broken.is_empty()
209 }
210}
211
212/// Resolve every internal link in the bundle at `root`.
213///
214/// # Errors
215///
216/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
217pub fn link_report(root: &Path) -> Result<LinkReport, InspectError> {
218 let bundle = load(root)?;
219 let links = bundle
220 .concepts()
221 .iter()
222 .map(|c| bundle.links_from(&c.id).len())
223 .sum();
224 Ok(LinkReport {
225 root: root.display().to_string(),
226 concepts: bundle.concepts().len(),
227 links,
228 broken: bundle
229 .broken_links()
230 .into_iter()
231 .map(|(from, target)| BrokenLink {
232 from: from.to_string(),
233 target,
234 })
235 .collect(),
236 })
237}
238
239/// A concept whose trust tier or lifecycle status moved between two bundles.
240#[derive(Debug, Clone, Serialize)]
241pub struct TrustMove {
242 /// The concept that moved.
243 pub id: String,
244 /// `(before, after)` tiers, when the tier changed.
245 pub tier: Option<(String, String)>,
246 /// `(before, after)` statuses, when the status changed.
247 pub status: Option<(String, String)>,
248}
249
250/// What changed between two bundles, semantically rather than by bytes.
251///
252/// ADR-0021 made `render okf` byte-deterministic specifically so "a consumer can
253/// diff two downloads and learn something". This is that diff, and it is the
254/// first thing in the workspace to exercise the determinism: `review --base`
255/// diffs code, not bundles.
256///
257/// A **rename** is the interesting field. A textual diff of two bundles reports
258/// a moved concept as one deletion and one unrelated addition; this reports it
259/// as a rename, which is the difference between "we lost a concept" and "we
260/// moved one".
261#[derive(Debug, Clone, Serialize)]
262pub struct DiffReport {
263 /// The bundle taken as "before".
264 pub before: String,
265 /// The bundle taken as "after".
266 pub after: String,
267 /// Concepts present only in `after`.
268 pub added: Vec<String>,
269 /// Concepts present only in `before`.
270 pub removed: Vec<String>,
271 /// Concepts whose path changed, as `(from, to)`.
272 pub renamed: Vec<(String, String)>,
273 /// Concepts whose body changed.
274 pub content_changed: Vec<String>,
275 /// Concepts whose frontmatter keys changed.
276 pub frontmatter_changed: Vec<String>,
277 /// Concepts whose tier or status moved. The one to read first.
278 pub trust_changed: Vec<TrustMove>,
279 /// Links that broke between `before` and `after`, as `(concept, target)`.
280 pub links_broken: Vec<(String, String)>,
281 /// Links that were broken in `before` and resolve in `after`.
282 pub links_mended: Vec<(String, String)>,
283}
284
285impl DiffReport {
286 /// `true` when the two bundles are semantically identical.
287 #[must_use]
288 pub fn is_unchanged(&self) -> bool {
289 self.added.is_empty()
290 && self.removed.is_empty()
291 && self.renamed.is_empty()
292 && self.content_changed.is_empty()
293 && self.frontmatter_changed.is_empty()
294 && self.trust_changed.is_empty()
295 && self.links_broken.is_empty()
296 && self.links_mended.is_empty()
297 }
298}
299
300/// Compare two bundles semantically.
301///
302/// # Errors
303///
304/// [`InspectError::Unreadable`] if either path is not a loadable OKF bundle.
305pub fn diff_report(before: &Path, after: &Path) -> Result<DiffReport, InspectError> {
306 let a = load(before)?;
307 let b = load(after)?;
308 let d = okf_core::bundle_diff(&a, &b);
309 let ids = |v: Vec<okf_core::ConceptId>| v.iter().map(ToString::to_string).collect::<Vec<_>>();
310 let pairs = |v: Vec<(okf_core::ConceptId, String)>| {
311 v.into_iter()
312 .map(|(id, t)| (id.to_string(), t))
313 .collect::<Vec<_>>()
314 };
315 Ok(DiffReport {
316 before: before.display().to_string(),
317 after: after.display().to_string(),
318 added: ids(d.added),
319 removed: ids(d.removed),
320 renamed: d
321 .renamed
322 .into_iter()
323 .map(|r| (r.from.to_string(), r.to.to_string()))
324 .collect(),
325 content_changed: ids(d.content),
326 frontmatter_changed: d.frontmatter.iter().map(|c| c.id.to_string()).collect(),
327 trust_changed: d
328 .trust
329 .into_iter()
330 .map(|t| TrustMove {
331 id: t.id.to_string(),
332 tier: t
333 .tier
334 .map(|(a, b)| (a.as_str().to_owned(), b.as_str().to_owned())),
335 status: t.status.map(|(a, b)| (a.to_string(), b.to_string())),
336 })
337 .collect(),
338 links_broken: pairs(d.broken_links),
339 links_mended: pairs(d.mended_links),
340 })
341}
342
343/// One code block that did not parse.
344#[derive(Debug, Clone, Serialize)]
345pub struct SyntaxFinding {
346 /// The concept the block belongs to.
347 pub concept: String,
348 /// The concept's file, relative to the bundle root.
349 pub path: String,
350 /// 1-indexed line of the block's opening fence within that file's body,
351 /// when it could be determined.
352 ///
353 /// `None` for a computation whose code this crate could not locate in the
354 /// body — an indented block with no `# Computation` heading to anchor it.
355 /// Reporting a confident `1` there was worse than reporting nothing: it sent
356 /// a reader to the frontmatter for a fault further down the file.
357 pub line: Option<usize>,
358 /// The language the block was tagged with, canonicalised.
359 pub language: String,
360 /// What the parser said.
361 pub message: String,
362}
363
364/// The result of syntax-checking a bundle's code blocks.
365///
366/// `checked` and `skipped` are both reported, deliberately. A language with no
367/// backend compiled in is *not checked* rather than *clean*, and a report that
368/// conflated the two would be a check that passes by not looking.
369#[derive(Debug, Clone, Serialize)]
370pub struct SyntaxReport {
371 /// The bundle root, as the caller named it.
372 pub root: String,
373 /// `computations` or `all-blocks` — what was looked at.
374 pub scope: &'static str,
375 /// Blocks a backend actually parsed.
376 pub checked: usize,
377 /// Blocks left alone, for any of three reasons: the block carried no
378 /// language tag, this build has no backend for the language it carried, or
379 /// the computation named a file rather than inlining its code.
380 ///
381 /// All three are "not looked at" rather than "looked at and clean", which is
382 /// the distinction the whole report exists to keep.
383 pub skipped: usize,
384 /// The languages this build can check, so a reader can tell why.
385 pub languages: Vec<String>,
386 /// Findings, in bundle order.
387 pub findings: Vec<SyntaxFinding>,
388}
389
390impl SyntaxReport {
391 /// `true` when nothing failed to parse.
392 #[must_use]
393 pub const fn passed(&self) -> bool {
394 self.findings.is_empty()
395 }
396}
397
398/// The language an untagged computation block should be read as.
399///
400/// Only `bigquery` is mapped, and only because the corpus justifies it: every
401/// `runtime:` in the four bundles published with the specification is
402/// `bigquery`, and the spec's own Attested Computation example writes its query
403/// as an *indented* block, which carries no info string. Without this the one
404/// case that matters most would never be checked.
405///
406/// Deliberately not a general runtime→language table. Inventing a mapping for
407/// runtimes nobody has written yet is how a reader ends up with a confident
408/// diagnostic about a language the author never claimed.
409fn language_for_runtime(runtime: Option<&str>) -> Option<&'static str> {
410 // Case-insensitive, because every other tag here is: `Language::from_tag`
411 // lowercases, so `runtime: BigQuery` reading differently from `bigquery`
412 // would be an inconsistency inside one function's worth of code.
413 match runtime.map(|r| r.trim().to_ascii_lowercase()).as_deref() {
414 Some("bigquery") => Some("sql"),
415 _ => None,
416 }
417}
418
419/// Syntax-check the code blocks in a bundle.
420///
421/// With `computations_only`, just the bodies of Attested Computations — the
422/// concepts that declare a `runtime:` and that an agent is expected to *run*, so
423/// the ones where "does this parse" is a question about the bundle rather than
424/// about its prose. Otherwise every fenced block in every document.
425///
426/// Findings are the checker's, not conformance: a bundle can be perfectly
427/// conformant and contain a code sample that does not parse, which is why this
428/// is its own command rather than part of validation.
429///
430/// # Errors
431///
432/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
433pub fn syntax_report(root: &Path, computations_only: bool) -> Result<SyntaxReport, InspectError> {
434 let bundle = load(root)?;
435 let languages = rto_okf_syntax::checkable_languages()
436 .into_iter()
437 .map(|l| l.as_str().to_owned())
438 .collect();
439 let mut report = SyntaxReport {
440 root: root.display().to_string(),
441 scope: if computations_only {
442 "computations"
443 } else {
444 "all-blocks"
445 },
446 checked: 0,
447 skipped: 0,
448 languages,
449 findings: Vec::new(),
450 };
451
452 for concept in bundle.concepts() {
453 let rel = concept
454 .path
455 .strip_prefix(bundle.root())
456 .unwrap_or(&concept.path)
457 .display()
458 .to_string();
459
460 if computations_only {
461 let Some(computation) = concept.attested_computation() else {
462 continue;
463 };
464 let okf_core::ComputationSource::Inline(inline) = &computation.computation else {
465 // A `computation:` file reference is checked by whatever owns
466 // that file, and a `Missing` one has no code to check at all.
467 // Counted as **skipped** rather than passed over silently: a
468 // bundle whose computations all name files would otherwise
469 // report "0 checked, 0 skipped" and print "nothing to check",
470 // which reads as "there were none" when there were several.
471 report.skipped += 1;
472 continue;
473 };
474 // An indented block carries no info string, so fall back to the
475 // declared runtime — see `language_for_runtime`.
476 let tag = inline
477 .language
478 .as_deref()
479 .or_else(|| language_for_runtime(computation.runtime.as_deref()))
480 .unwrap_or("");
481 let line = computation_line(&concept.document.body, &inline.code);
482 record(
483 &mut report,
484 &concept.id.to_string(),
485 &rel,
486 line,
487 tag,
488 &inline.code,
489 );
490 } else {
491 for block in rto_okf_syntax::extract_fenced_code_blocks(&concept.document.body) {
492 let tag = block.language.as_deref().unwrap_or("");
493 record(
494 &mut report,
495 &concept.id.to_string(),
496 &rel,
497 Some(block.start_line),
498 tag,
499 &block.code,
500 );
501 }
502 }
503 }
504
505 Ok(report)
506}
507
508/// Where a computation's code starts in its document.
509///
510/// The fenced case is exact: the same extractor the all-blocks path uses finds
511/// the block whose contents are the computation's, and reports its opening
512/// fence. The indented case cannot be — `okf-core` dedents the code, so it no
513/// longer matches the file byte for byte — and the `# Computation` heading is the
514/// honest anchor there: it is where a reader should look, even though it is not
515/// where the parser stopped.
516///
517/// `None` rather than a confident `1` when neither is found. Pointing a reader at
518/// the frontmatter for a fault further down the file is worse than admitting the
519/// line is unknown.
520fn computation_line(body: &str, code: &str) -> Option<usize> {
521 let wanted = code.trim();
522 if let Some(block) = rto_okf_syntax::extract_fenced_code_blocks(body)
523 .into_iter()
524 .find(|b| b.code.trim() == wanted)
525 {
526 return Some(block.start_line);
527 }
528 body.lines().enumerate().find_map(|(i, l)| {
529 l.trim_start()
530 .strip_prefix('#')
531 .is_some_and(|rest| rest.trim().eq_ignore_ascii_case("computation"))
532 .then_some(i + 1)
533 })
534}
535
536/// Check one block and fold the outcome into the report.
537fn record(
538 report: &mut SyntaxReport,
539 concept: &str,
540 path: &str,
541 line: Option<usize>,
542 tag: &str,
543 code: &str,
544) {
545 let language = rto_okf_syntax::Language::from_tag(tag);
546 if !rto_okf_syntax::is_checkable(language) {
547 report.skipped += 1;
548 return;
549 }
550 report.checked += 1;
551 if let Err(err) = rto_okf_syntax::check_syntax(tag, code) {
552 report.findings.push(SyntaxFinding {
553 concept: concept.to_owned(),
554 path: path.to_owned(),
555 line,
556 language: err.language.clone(),
557 message: err.to_string(),
558 });
559 }
560}