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    /// `--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}