Skip to main content

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}