rto-render 5.8.1

Renderers for Roteiro: docs site, Obsidian vault, and optional MCP server. Implementation detail of the roteiro CLI; no API stability guarantee.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
//! Inspect an OKF bundle **as a bundle**, without importing it.
//!
//! [`read`](super::read) answers "what would this add to the graph". This module
//! answers questions about the bundle itself — what it claims, whether it hangs
//! together, how it differs from another copy — and answers them with somebody
//! else's implementation of the specification.
//!
//! # Why an independent implementation is the whole value
//!
//! Roteiro both *writes* OKF (`render okf`) and *reads* it (`import --from
//! okf`). A reader of our own construction, run over our own output, would
//! agree with us about a format we also invent: it can only catch a mistake we
//! did not make twice. `okf-core` is an independent reading of the same
//! specification by an author who is not us, so its disagreement is
//! *information*.
//!
//! That is not hypothetical here. ADR-0021 records that deriving a concept's
//! path from its node key "guessed wrong for 43 links" in a real render, and the
//! reader's own YAML subset silently dropped every human sign-off in Google's
//! published bundles until an independent oracle was pointed at it. Both were
//! found by checking our output against something that did not share our
//! assumptions.
//!
//! # What is here, and what is not
//!
//! [`trust_summary`], [`link_report`] and [`diff_report`], all built on
//! `okf-core` — **one crate, zero transitive dependencies**.
//!
//! Conformance checking and hygiene linting are **not** here. They live
//! upstream in a second crate, `okf-validator`, whose dependencies are not
//! optional and which syntax-checks fenced code blocks in eight languages.
//! Taking it means taking `rustpython-parser`: 61 crates, `LGPL-3.0-only`
//! through the `malachite` tree, and six unmaintained advisories whose own text
//! says no safe upgrade exists. `cargo deny` refuses it on both counts, and
//! ADR-0017 §3 is explicit that a licence is not admitted merely to turn CI
//! green.
//!
//! That price bought two of the validator's thirty-four checks, both of them
//! about whether embedded *code* parses rather than whether the *bundle*
//! conforms. See `Cargo.toml` for the full measurement.
//!
//! # Subcommand names are upstream's
//!
//! `trust`, `links` and `diff` match the `okf` CLI's own names for the same
//! operations, so somebody who knows that tool already knows this one. The
//! library is called **in-process**; Roteiro is a self-contained offline binary
//! and requiring `okf` on `PATH` would reintroduce exactly the coupling the
//! vendored interop fixtures exist to avoid.

use std::path::Path;

use okf_core::{Bundle, TrustTier};
use serde::Serialize;

/// Why a bundle could not be inspected.
///
/// One variant today: every failure here is "the path is not a bundle we could
/// load". The underlying [`okf_core::BundleError`] is rendered into the message
/// rather than wrapped, so this type stays free of the dependency in its public
/// shape.
///
/// `#[non_exhaustive]` because that set is closed by nothing but current
/// implementation — unlike [`super::Actor`], whose three variants are closed by
/// §7 of the specification and which is deliberately exhaustive for that reason.
/// A second failure mode here (a bundle that loads but declares an OKF version
/// this crate cannot read, say) is an ordinary addition, and these crates are
/// published, so it must not be a breaking change.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum InspectError {
    /// The path could not be loaded as an OKF bundle.
    #[error("`{path}` is not a readable OKF bundle: {detail}")]
    Unreadable {
        /// The path as the caller gave it.
        path: String,
        /// What `okf-core` said went wrong.
        detail: String,
    },
    /// `--today` was given a value that is not an ISO `YYYY-MM-DD` date.
    ///
    /// Refused rather than silently falling back to the real clock: the flag
    /// exists so a run is reproducible, and a typo that quietly restored
    /// today's date would make a green pipeline mean nothing.
    #[error("`{given}` is not an ISO date (expected YYYY-MM-DD)")]
    BadDate {
        /// The value as the caller gave it.
        given: String,
    },
    /// The host clock could not be read and no `--today` was given.
    #[error("cannot read the current date; pass --today YYYY-MM-DD")]
    NoClock,
}

/// Load a bundle, naming the path in the error rather than only the cause.
pub(super) fn load(root: &Path) -> Result<Bundle, InspectError> {
    Bundle::load(root).map_err(|e| InspectError::Unreadable {
        path: root.display().to_string(),
        detail: e.to_string(),
    })
}

/// A concept's trust claim, as the bundle states it.
#[derive(Debug, Clone, Serialize)]
pub struct ConceptTrust {
    /// The concept's path within the bundle, minus `.md`.
    pub id: String,
    /// §5.3's tier: `human-reviewed`, `machine-confirmed` or `unverified`.
    pub tier: &'static str,
    /// The lifecycle `status` §5.4 resolves for this concept.
    pub status: String,
    /// Every actor named in `verified`, in the order the document wrote them.
    ///
    /// Present even when the tier is `unverified`: an event with an unparseable
    /// timestamp does not count toward the tier but is still an attribution the
    /// bundle made, and dropping it would hide *why* the tier came out low.
    pub verified_by: Vec<String>,
    /// The `stale_after` timestamp exactly as the document wrote it, if any.
    pub stale_after: Option<String>,
    /// Whether `today >= stale_after` (§5.4).
    ///
    /// Independent of `tier`: a concept can be human-reviewed *and* stale, and
    /// that combination is the one most worth seeing before an import, because
    /// the tier alone reads as reassurance.
    pub stale: bool,
}

/// What a bundle claims about its own trustworthiness.
///
/// This is the answer to "should I trust this bundle", stated per concept and in
/// aggregate, and it is deliberately a **plain data type over a path**: it is
/// exactly the information a consent prompt wants at the moment it asks, and
/// nothing here needs the import machinery to have run first.
#[derive(Debug, Clone, Serialize)]
pub struct TrustSummary {
    /// The bundle root, as the caller named it.
    pub root: String,
    /// The `okf_version` the root `index.md` declares (§10), if any.
    pub okf_version: Option<String>,
    /// Concepts read, excluding the reserved `index.md` / `log.md` files.
    pub total: usize,
    /// Concepts carrying at least one valid `human:` verifier.
    pub human_reviewed: usize,
    /// Concepts verified only by non-`human:` actors.
    pub machine_confirmed: usize,
    /// Concepts with no valid `verified` event.
    pub unverified: usize,
    /// Concepts whose `stale_after` has passed, as of `today`.
    pub stale: usize,
    /// The date staleness was judged against, as `YYYY-MM-DD`.
    ///
    /// Always reported, whether it came from `--today` or the host clock, so a
    /// captured summary says what it was true *of*. A tiered count with no date
    /// beside it cannot be compared with the same bundle read a month later.
    pub today: String,
    /// Every concept, in bundle order.
    pub concepts: Vec<ConceptTrust>,
}

/// Derive [`TrustSummary`] for the bundle at `root`.
///
/// # Errors
///
/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle,
/// [`InspectError::BadDate`] if `today` is given and is not an ISO date, and
/// [`InspectError::NoClock`] if `today` is `None` and the host date cannot be
/// read. The last is the one worth handling deliberately: it is the only way
/// this fails on a perfectly good bundle, and the remedy is to pass `today`.
pub fn trust_summary(root: &Path, today: Option<&str>) -> Result<TrustSummary, InspectError> {
    let today = resolve_today(today)?;
    Ok(summarise_trust(
        &load(root)?,
        &root.display().to_string(),
        today,
    ))
}

/// The date staleness is judged against: `--today` when given, else the host's
/// UTC date.
///
/// Separated out because it is the only non-deterministic input in this module,
/// and every report that mentions staleness takes it the same way.
fn resolve_today(given: Option<&str>) -> Result<okf_core::Date, InspectError> {
    match given {
        Some(raw) => okf_core::Date::parse(raw).ok_or_else(|| InspectError::BadDate {
            given: raw.to_owned(),
        }),
        None => okf_core::Date::today_utc().ok_or(InspectError::NoClock),
    }
}

/// The bundle-in-hand half of [`trust_summary`].
///
/// Split out so a caller that has already loaded a [`Bundle`] — to validate it,
/// or to ask a person whether to import it — pays for the directory walk once.
#[must_use]
pub fn summarise_trust(bundle: &Bundle, root: &str, today: okf_core::Date) -> TrustSummary {
    let mut summary = TrustSummary {
        root: root.to_owned(),
        okf_version: bundle.okf_version().map(ToOwned::to_owned),
        total: bundle.concepts().len(),
        human_reviewed: 0,
        machine_confirmed: 0,
        unverified: 0,
        stale: 0,
        today: today.to_string(),
        concepts: Vec::with_capacity(bundle.concepts().len()),
    };
    for concept in bundle.concepts() {
        let tier = concept.trust_tier();
        match tier {
            TrustTier::HumanReviewed => summary.human_reviewed += 1,
            TrustTier::MachineConfirmed => summary.machine_confirmed += 1,
            TrustTier::Unverified => summary.unverified += 1,
        }
        let stale = concept.is_stale_on(today);
        if stale {
            summary.stale += 1;
        }
        summary.concepts.push(ConceptTrust {
            id: concept.id.to_string(),
            tier: tier.as_str(),
            status: concept.status().to_string(),
            stale_after: concept
                .document
                .frontmatter
                .stale_after()
                .map(|d| d.to_string()),
            stale,
            verified_by: concept
                .document
                .frontmatter
                .verified()
                .into_iter()
                .filter_map(|v| v.by.map(|by| by.as_str().to_owned()))
                .collect(),
        });
    }
    summary
}

/// A markdown link that names a concept the bundle does not contain.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BrokenLink {
    /// The concept whose body carries the link.
    pub from: String,
    /// The link target, exactly as written.
    pub target: String,
}

/// Whether an emitted bundle's internal links resolve.
///
/// Roteiro's own link checking (`roteiro check`) covers the **graph** and the
/// **rendered site**. Neither looks at an emitted OKF bundle, which is a third
/// artefact produced by a third code path — the one ADR-0021 records guessing
/// wrong for 43 links.
#[derive(Debug, Clone, Serialize)]
pub struct LinkReport {
    /// The bundle root, as the caller named it.
    pub root: String,
    /// Concepts read.
    pub concepts: usize,
    /// Internal concept links found across every body.
    pub links: usize,
    /// Those that resolve to no concept in the bundle.
    pub broken: Vec<BrokenLink>,
}

impl LinkReport {
    /// `true` when every internal link resolves.
    #[must_use]
    pub const fn is_clean(&self) -> bool {
        self.broken.is_empty()
    }
}

/// Resolve every internal link in the bundle at `root`.
///
/// # Errors
///
/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
pub fn link_report(root: &Path) -> Result<LinkReport, InspectError> {
    let bundle = load(root)?;
    let links = bundle
        .concepts()
        .iter()
        .map(|c| bundle.links_from(&c.id).len())
        .sum();
    Ok(LinkReport {
        root: root.display().to_string(),
        concepts: bundle.concepts().len(),
        links,
        broken: bundle
            .broken_links()
            .into_iter()
            .map(|(from, target)| BrokenLink {
                from: from.to_string(),
                target,
            })
            .collect(),
    })
}

/// A concept whose trust tier or lifecycle status moved between two bundles.
#[derive(Debug, Clone, Serialize)]
pub struct TrustMove {
    /// The concept that moved.
    pub id: String,
    /// `(before, after)` tiers, when the tier changed.
    pub tier: Option<(String, String)>,
    /// `(before, after)` statuses, when the status changed.
    pub status: Option<(String, String)>,
}

/// What changed between two bundles, semantically rather than by bytes.
///
/// ADR-0021 made `render okf` byte-deterministic specifically so "a consumer can
/// diff two downloads and learn something". This is that diff, and it is the
/// first thing in the workspace to exercise the determinism: `review --base`
/// diffs code, not bundles.
///
/// A **rename** is the interesting field. A textual diff of two bundles reports
/// a moved concept as one deletion and one unrelated addition; this reports it
/// as a rename, which is the difference between "we lost a concept" and "we
/// moved one".
#[derive(Debug, Clone, Serialize)]
pub struct DiffReport {
    /// The bundle taken as "before".
    pub before: String,
    /// The bundle taken as "after".
    pub after: String,
    /// Concepts present only in `after`.
    pub added: Vec<String>,
    /// Concepts present only in `before`.
    pub removed: Vec<String>,
    /// Concepts whose path changed, as `(from, to)`.
    pub renamed: Vec<(String, String)>,
    /// Concepts whose body changed.
    pub content_changed: Vec<String>,
    /// Concepts whose frontmatter keys changed.
    pub frontmatter_changed: Vec<String>,
    /// Concepts whose tier or status moved. The one to read first.
    pub trust_changed: Vec<TrustMove>,
    /// Links that broke between `before` and `after`, as `(concept, target)`.
    pub links_broken: Vec<(String, String)>,
    /// Links that were broken in `before` and resolve in `after`.
    pub links_mended: Vec<(String, String)>,
}

impl DiffReport {
    /// `true` when the two bundles are semantically identical.
    #[must_use]
    pub fn is_unchanged(&self) -> bool {
        self.added.is_empty()
            && self.removed.is_empty()
            && self.renamed.is_empty()
            && self.content_changed.is_empty()
            && self.frontmatter_changed.is_empty()
            && self.trust_changed.is_empty()
            && self.links_broken.is_empty()
            && self.links_mended.is_empty()
    }
}

/// Compare two bundles semantically.
///
/// # Errors
///
/// [`InspectError::Unreadable`] if either path is not a loadable OKF bundle.
pub fn diff_report(before: &Path, after: &Path) -> Result<DiffReport, InspectError> {
    let a = load(before)?;
    let b = load(after)?;
    let d = okf_core::bundle_diff(&a, &b);
    let ids = |v: Vec<okf_core::ConceptId>| v.iter().map(ToString::to_string).collect::<Vec<_>>();
    let pairs = |v: Vec<(okf_core::ConceptId, String)>| {
        v.into_iter()
            .map(|(id, t)| (id.to_string(), t))
            .collect::<Vec<_>>()
    };
    Ok(DiffReport {
        before: before.display().to_string(),
        after: after.display().to_string(),
        added: ids(d.added),
        removed: ids(d.removed),
        renamed: d
            .renamed
            .into_iter()
            .map(|r| (r.from.to_string(), r.to.to_string()))
            .collect(),
        content_changed: ids(d.content),
        frontmatter_changed: d.frontmatter.iter().map(|c| c.id.to_string()).collect(),
        trust_changed: d
            .trust
            .into_iter()
            .map(|t| TrustMove {
                id: t.id.to_string(),
                tier: t
                    .tier
                    .map(|(a, b)| (a.as_str().to_owned(), b.as_str().to_owned())),
                status: t.status.map(|(a, b)| (a.to_string(), b.to_string())),
            })
            .collect(),
        links_broken: pairs(d.broken_links),
        links_mended: pairs(d.mended_links),
    })
}

/// One code block that did not parse.
#[derive(Debug, Clone, Serialize)]
pub struct SyntaxFinding {
    /// The concept the block belongs to.
    pub concept: String,
    /// The concept's file, relative to the bundle root.
    pub path: String,
    /// 1-indexed line of the block's opening fence within that file's body,
    /// when it could be determined.
    ///
    /// `None` for a computation whose code this crate could not locate in the
    /// body — an indented block with no `# Computation` heading to anchor it.
    /// Reporting a confident `1` there was worse than reporting nothing: it sent
    /// a reader to the frontmatter for a fault further down the file.
    pub line: Option<usize>,
    /// The language the block was tagged with, canonicalised.
    pub language: String,
    /// What the parser said.
    pub message: String,
}

/// The result of syntax-checking a bundle's code blocks.
///
/// `checked` and `skipped` are both reported, deliberately. A language with no
/// backend compiled in is *not checked* rather than *clean*, and a report that
/// conflated the two would be a check that passes by not looking.
#[derive(Debug, Clone, Serialize)]
pub struct SyntaxReport {
    /// The bundle root, as the caller named it.
    pub root: String,
    /// `computations` or `all-blocks` — what was looked at.
    pub scope: &'static str,
    /// Blocks a backend actually parsed.
    pub checked: usize,
    /// Blocks left alone, for any of three reasons: the block carried no
    /// language tag, this build has no backend for the language it carried, or
    /// the computation named a file rather than inlining its code.
    ///
    /// All three are "not looked at" rather than "looked at and clean", which is
    /// the distinction the whole report exists to keep.
    pub skipped: usize,
    /// The languages this build can check, so a reader can tell why.
    pub languages: Vec<String>,
    /// Findings, in bundle order.
    pub findings: Vec<SyntaxFinding>,
}

impl SyntaxReport {
    /// `true` when nothing failed to parse.
    #[must_use]
    pub const fn passed(&self) -> bool {
        self.findings.is_empty()
    }
}

/// The language an untagged computation block should be read as.
///
/// Only `bigquery` is mapped, and only because the corpus justifies it: every
/// `runtime:` in the four bundles published with the specification is
/// `bigquery`, and the spec's own Attested Computation example writes its query
/// as an *indented* block, which carries no info string. Without this the one
/// case that matters most would never be checked.
///
/// Deliberately not a general runtime→language table. Inventing a mapping for
/// runtimes nobody has written yet is how a reader ends up with a confident
/// diagnostic about a language the author never claimed.
fn language_for_runtime(runtime: Option<&str>) -> Option<&'static str> {
    // Case-insensitive, because every other tag here is: `Language::from_tag`
    // lowercases, so `runtime: BigQuery` reading differently from `bigquery`
    // would be an inconsistency inside one function's worth of code.
    match runtime.map(|r| r.trim().to_ascii_lowercase()).as_deref() {
        Some("bigquery") => Some("sql"),
        _ => None,
    }
}

/// Syntax-check the code blocks in a bundle.
///
/// With `computations_only`, just the bodies of Attested Computations — the
/// concepts that declare a `runtime:` and that an agent is expected to *run*, so
/// the ones where "does this parse" is a question about the bundle rather than
/// about its prose. Otherwise every fenced block in every document.
///
/// Findings are the checker's, not conformance: a bundle can be perfectly
/// conformant and contain a code sample that does not parse, which is why this
/// is its own command rather than part of validation.
///
/// # Errors
///
/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
pub fn syntax_report(root: &Path, computations_only: bool) -> Result<SyntaxReport, InspectError> {
    let bundle = load(root)?;
    let languages = rto_okf_syntax::checkable_languages()
        .into_iter()
        .map(|l| l.as_str().to_owned())
        .collect();
    let mut report = SyntaxReport {
        root: root.display().to_string(),
        scope: if computations_only {
            "computations"
        } else {
            "all-blocks"
        },
        checked: 0,
        skipped: 0,
        languages,
        findings: Vec::new(),
    };

    for concept in bundle.concepts() {
        let rel = concept
            .path
            .strip_prefix(bundle.root())
            .unwrap_or(&concept.path)
            .display()
            .to_string();

        if computations_only {
            let Some(computation) = concept.attested_computation() else {
                continue;
            };
            let okf_core::ComputationSource::Inline(inline) = &computation.computation else {
                // A `computation:` file reference is checked by whatever owns
                // that file, and a `Missing` one has no code to check at all.
                // Counted as **skipped** rather than passed over silently: a
                // bundle whose computations all name files would otherwise
                // report "0 checked, 0 skipped" and print "nothing to check",
                // which reads as "there were none" when there were several.
                report.skipped += 1;
                continue;
            };
            // An indented block carries no info string, so fall back to the
            // declared runtime — see `language_for_runtime`.
            let tag = inline
                .language
                .as_deref()
                .or_else(|| language_for_runtime(computation.runtime.as_deref()))
                .unwrap_or("");
            let line = computation_line(&concept.document.body, &inline.code);
            record(
                &mut report,
                &concept.id.to_string(),
                &rel,
                line,
                tag,
                &inline.code,
            );
        } else {
            for block in rto_okf_syntax::extract_fenced_code_blocks(&concept.document.body) {
                let tag = block.language.as_deref().unwrap_or("");
                record(
                    &mut report,
                    &concept.id.to_string(),
                    &rel,
                    Some(block.start_line),
                    tag,
                    &block.code,
                );
            }
        }
    }

    Ok(report)
}

/// Where a computation's code starts in its document.
///
/// The fenced case is exact: the same extractor the all-blocks path uses finds
/// the block whose contents are the computation's, and reports its opening
/// fence. The indented case cannot be — `okf-core` dedents the code, so it no
/// longer matches the file byte for byte — and the `# Computation` heading is the
/// honest anchor there: it is where a reader should look, even though it is not
/// where the parser stopped.
///
/// `None` rather than a confident `1` when neither is found. Pointing a reader at
/// the frontmatter for a fault further down the file is worse than admitting the
/// line is unknown.
fn computation_line(body: &str, code: &str) -> Option<usize> {
    let wanted = code.trim();
    if let Some(block) = rto_okf_syntax::extract_fenced_code_blocks(body)
        .into_iter()
        .find(|b| b.code.trim() == wanted)
    {
        return Some(block.start_line);
    }
    body.lines().enumerate().find_map(|(i, l)| {
        l.trim_start()
            .strip_prefix('#')
            .is_some_and(|rest| rest.trim().eq_ignore_ascii_case("computation"))
            .then_some(i + 1)
    })
}

/// Check one block and fold the outcome into the report.
fn record(
    report: &mut SyntaxReport,
    concept: &str,
    path: &str,
    line: Option<usize>,
    tag: &str,
    code: &str,
) {
    let language = rto_okf_syntax::Language::from_tag(tag);
    if !rto_okf_syntax::is_checkable(language) {
        report.skipped += 1;
        return;
    }
    report.checked += 1;
    if let Err(err) = rto_okf_syntax::check_syntax(tag, code) {
        report.findings.push(SyntaxFinding {
            concept: concept.to_owned(),
            path: path.to_owned(),
            line,
            language: err.language.clone(),
            message: err.to_string(),
        });
    }
}

/// One concept's Attested Computation (§10), as the bundle declares it.
#[derive(Debug, Clone, Serialize)]
pub struct ComputationEntry {
    /// The concept carrying the contract.
    pub concept: String,
    /// The bundle-relative file it lives in.
    pub path: String,
    /// §10's `runtime`, which decides how everything else is interpreted.
    ///
    /// `None` is a conformance error, not an absence — the spec makes it
    /// REQUIRED — and it is surfaced here rather than skipped so a listing and
    /// `okf validate` agree about what the bundle contains.
    pub runtime: Option<String>,
    /// `inline`, `file` or `missing`.
    pub source: &'static str,
    /// The file named by a `computation:` key, when `source` is `file`.
    pub file: Option<String>,
    /// The fenced language of an inline block, when it declared one.
    pub language: Option<String>,
    /// Lines of code in an inline block.
    pub lines: Option<usize>,
    /// The named holes an agent may fill.
    pub parameters: Vec<String>,
    /// Whether an executor is declared.
    pub has_executor: bool,
    /// Whether an attester is declared.
    pub has_attester: bool,
    /// `true` when the concept carries **both** an inline block and a
    /// `computation:` file key.
    ///
    /// The spec asks for one or the other, so the two halves can disagree with
    /// nothing to arbitrate between them. Listed rather than merely counted
    /// because the fix is per concept.
    pub redundant_inline: bool,
}

/// Every Attested Computation a bundle declares.
#[derive(Debug, Clone, Serialize)]
pub struct ComputationReport {
    /// The bundle root, as the caller named it.
    pub root: String,
    /// Concepts read.
    pub concepts: usize,
    /// Concepts carrying a computation contract.
    pub computations: usize,
    /// Of those, how many carry the code inline.
    pub inline: usize,
    /// Of those, how many name a file instead.
    pub file: usize,
    /// Of those, how many declare neither — an incomplete contract.
    pub missing: usize,
    /// Every distinct `runtime`, sorted.
    pub runtimes: Vec<String>,
    /// The contracts themselves, in bundle order.
    pub entries: Vec<ComputationEntry>,
}

impl ComputationReport {
    /// Whether every contract found is complete: a runtime, and code somewhere.
    ///
    /// This is what `--check` gates on. A bundle with **no** computations is
    /// clean by this measure, which is the right answer: §10 is optional, and
    /// failing a bundle for not using an optional feature would make the gate
    /// unusable on the three of four published bundles that declare none.
    #[must_use]
    pub fn is_clean(&self) -> bool {
        self.incomplete() == 0
    }

    /// Contracts that are declared but not usable: no `runtime`, no code, or
    /// both an inline block and a file with nothing to arbitrate between them.
    #[must_use]
    pub fn incomplete(&self) -> usize {
        self.entries
            .iter()
            .filter(|e| e.runtime.is_none() || e.source == "missing" || e.redundant_inline)
            .count()
    }
}

/// List the Attested Computations in the bundle at `root`.
///
/// # Errors
///
/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle.
pub fn computation_report(root: &Path) -> Result<ComputationReport, InspectError> {
    let bundle = load(root)?;
    let mut report = ComputationReport {
        root: root.display().to_string(),
        concepts: bundle.concepts().len(),
        computations: 0,
        inline: 0,
        file: 0,
        missing: 0,
        runtimes: Vec::new(),
        entries: Vec::new(),
    };
    let mut runtimes = std::collections::BTreeSet::new();

    for concept in bundle.concepts() {
        let Some(computation) = concept.attested_computation() else {
            continue;
        };
        report.computations += 1;
        if let Some(runtime) = computation.runtime.as_deref() {
            runtimes.insert(runtime.to_owned());
        }
        let (source, file, language, lines) = match &computation.computation {
            okf_core::ComputationSource::Inline(inline) => {
                report.inline += 1;
                (
                    "inline",
                    None,
                    inline.language.clone(),
                    Some(inline.code.lines().count()),
                )
            }
            okf_core::ComputationSource::File(path) => {
                report.file += 1;
                ("file", Some(path.clone()), None, None)
            }
            okf_core::ComputationSource::Missing => {
                report.missing += 1;
                ("missing", None, None, None)
            }
        };
        report.entries.push(ComputationEntry {
            concept: concept.id.to_string(),
            path: concept
                .path
                .strip_prefix(bundle.root())
                .unwrap_or(&concept.path)
                .display()
                .to_string(),
            runtime: computation.runtime.clone(),
            source,
            file,
            language,
            lines,
            // An unnamed parameter is dropped rather than rendered as a hole:
            // §10 requires the name, so `okf validate` is what reports its
            // absence, and repeating it here as an empty slot in a listing would
            // read as a parameter called "".
            parameters: computation
                .parameters
                .iter()
                .filter_map(|p| p.name.clone())
                .collect(),
            has_executor: computation.executor.is_some(),
            has_attester: computation.attester.is_some(),
            redundant_inline: computation.has_redundant_inline,
        });
    }
    report.runtimes = runtimes.into_iter().collect();
    Ok(report)
}

/// A file a bundle carries that is not one of its concepts.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BundleFile {
    /// Bundle-relative path.
    pub path: String,
    /// Size in bytes, or `None` when it could not be read.
    ///
    /// Distinguished from zero rather than conflated with it, because an empty
    /// file and an unstattable one are different facts and only one of them is
    /// reassuring — the same reason the walk reports what it could not open. A
    /// caller summing sizes treats `None` as contributing nothing; a caller
    /// printing one says so.
    pub bytes: Option<u64>,
    /// Lowercased extension, or `""` when the file has none.
    pub extension: String,
}

/// What a bundle carries that is not one of its concepts.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct BundleContents {
    /// Every non-markdown file, ordered by path.
    pub files: Vec<BundleFile>,
    /// Everything the walk could not inspect, so the inventory above is
    /// **incomplete**.
    ///
    /// Reported rather than swallowed. An inventory that answers "none" because
    /// something would not open is the same false reassurance this feature exists
    /// to remove — a reader would take silence for absence, which is precisely
    /// what "0 violations" over an unread PDF did.
    ///
    /// Three failures land here, not one: a directory that will not list, a
    /// directory entry that will not yield, and an entry whose type cannot be
    /// read. The first was the obvious case and the other two are the same defect
    /// one level in — `entries.flatten()` and a `let Ok(kind) = … else continue`
    /// each discard an error and leave `is_complete()` saying the walk saw
    /// everything.
    ///
    /// A file whose **size** cannot be read is not here: the file itself was
    /// seen, named and reported, so the inventory is complete. See
    /// [`BundleFile::bytes`].
    pub unreadable: Vec<String>,
}

impl BundleContents {
    /// Whether the walk saw everything it tried to.
    #[must_use]
    pub fn is_complete(&self) -> bool {
        self.unreadable.is_empty()
    }
}

/// Every file in the bundle that is not markdown.
///
/// **Nothing here is opened.** The path, the size and the extension come from
/// the directory entry and its metadata; the bytes are never read, so this adds
/// no parser and no attack surface of its own.
///
/// It exists because a bundle is **not** markdown, whatever the four published
/// ones happen to contain: `okf-core`'s `resolve_path_field` resolves a
/// frontmatter path to any file with `is_file()` and no extension filter, and
/// §10's `computation:` names a file. So a conformant bundle can cite a document
/// nobody here can read — and until now every report we produced would call that
/// bundle clean without mentioning the document existed (ADR-0024).
///
/// **What it costs**, because it looks cheaper than it is and has more than one
/// caller: one walk of this repository's own 9,633-file bundle measures **5.9 ms
/// warm** (40 ms cold), against the `Bundle::load` of the same bundle at
/// **1.29 s** — which every caller has already paid before reaching here, since
/// there is nothing to report about a bundle that did not load. Two walks in a
/// run is under one percent of what the run already spent, so this is not cached.
/// If that ratio changes, cache it then and put the new number here.
///
/// Symlinked directories are **not** followed: this walks a directory a peer
/// controls, and `loop -> ..` inside one would otherwise never terminate.
/// Entries are classified with `file_type()`, which reads the directory entry
/// rather than the link's target, and a symlink is counted as the file it is.
///
/// The walk keeps its own stack rather than recursing. Review raised a deep-tree
/// stack overflow, and it does **not** reproduce — `PATH_MAX` caps the depth an
/// attacker can build (509 here) and `Bundle::load` refuses such a tree before
/// this ever runs, with `File name too long`. It is iterative anyway, because
/// "bounded by the filesystem's path limit" is a platform accident rather than a
/// property of this function, and an explicit stack costs nothing to make it one.
#[must_use]
pub fn bundle_files(root: &Path) -> BundleContents {
    let mut out = BundleContents::default();
    let mut stack = vec![root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            out.unreadable.push(relative(root, &dir));
            continue;
        };
        for entry in entries {
            // Both of these were `flatten()` and `else continue`, which discard
            // an error and then let `is_complete()` claim the walk saw
            // everything — the swallowed-failure defect this type exists to
            // report, one level further in.
            let Ok(entry) = entry else {
                out.unreadable.push(relative(root, &dir));
                continue;
            };
            let path = entry.path();
            let Ok(kind) = entry.file_type() else {
                out.unreadable.push(relative(root, &path));
                continue;
            };
            if kind.is_dir() {
                stack.push(path);
                continue;
            }
            let extension = path
                .extension()
                .and_then(|e| e.to_str())
                .map(str::to_ascii_lowercase)
                .unwrap_or_default();
            if extension == "md" {
                continue;
            }
            out.files.push(BundleFile {
                path: relative(root, &path),
                bytes: std::fs::symlink_metadata(&path).map(|m| m.len()).ok(),
                extension,
            });
        }
    }
    // Sorted so two reads of one bundle, and two bundles with the same contents,
    // report identically — the same determinism `render okf` guarantees. The
    // stack alone gives no order at all, since it pops depth-first in whatever
    // order the filesystem returned each directory.
    out.files.sort_by(|a, b| a.path.cmp(&b.path));
    // A directory that failed to yield several entries names itself once per
    // failure, and the count is not information a reader can act on.
    out.unreadable.sort();
    out.unreadable.dedup();
    out
}

/// A bundle-relative path, with `/` separators on every platform.
///
/// `Path::display()` alone emits `\` on Windows, and this string is compared
/// against the `/`-separated paths a bundle's own frontmatter and links use — so
/// on Windows an inventory entry would not match the document that cited it.
///
/// A path that is somehow **not** under `root` is rendered as the bare file name
/// rather than falling back to the whole path: the fallback would print an
/// absolute path from the host into a report about a peer's bundle, which is a
/// small disclosure to make in a message whose subject is what a stranger can
/// see.
///
/// The **root itself** renders as `"."`, never as the empty string. It reaches
/// here when the bundle root is the thing that will not list, and `strip_prefix`
/// against itself yields an empty path — so the report read `1 entry could not be
/// inspected:` followed by a blank line, which is a worse failure than the one
/// being reported, in the one message whose whole job is to say what could not be
/// seen. `"."` is the spelling `AdrHome::dir` already uses for "the root" here.
fn relative(root: &Path, path: &Path) -> String {
    let rel = path
        .strip_prefix(root)
        .unwrap_or_else(|_| Path::new(path.file_name().unwrap_or(std::ffi::OsStr::new("?"))));
    let joined = rel
        .components()
        .map(|c| c.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/");
    // The root itself yields an empty path from `strip_prefix` against itself.
    if joined.is_empty() {
        ".".to_owned()
    } else {
        joined
    }
}

/// What a bundle is, in one answer.
///
/// Composed from the reports the other commands already produce rather than
/// re-deriving anything: this is the command you run *first*, on a bundle
/// somebody handed you, to decide which of the others is worth running.
#[derive(Debug, Clone, Serialize)]
pub struct BundleInfo {
    /// The bundle root, as the caller named it.
    pub root: String,
    /// The `okf_version` the root `index.md` declares (§10), if any.
    ///
    /// Absent is conformant — §8 and §12 make it MAY — so this is reported and
    /// never warned about.
    pub okf_version: Option<String>,
    /// The bundle's title, from `index.md`.
    pub title: Option<String>,
    /// Concepts, excluding the reserved `index.md` / `log.md`.
    pub concepts: usize,
    /// Trust tiers and staleness, as of `today`.
    pub trust: TrustSummary,
    /// How many concepts carry each `status`, sorted by status.
    pub statuses: Vec<(String, usize)>,
    /// Internal links, and how many resolve to nothing.
    pub links: (usize, usize),
    /// Attested Computations, and how many are incomplete.
    pub computations: (usize, usize),
    /// Every distinct computation `runtime`, sorted.
    pub runtimes: Vec<String>,
    /// Files the bundle carries that are not concepts, and any directory the
    /// walk could not list — see [`bundle_files`].
    ///
    /// Reported whether or not there are any, because "no unscreenable files" is
    /// information and a line that appears only sometimes is one a reader learns
    /// to stop looking for.
    pub files: BundleContents,
}

/// Summarise the bundle at `root`.
///
/// # Errors
///
/// [`InspectError::Unreadable`] if the path is not a loadable OKF bundle,
/// [`InspectError::BadDate`] if `today` is given and is not an ISO date, and
/// [`InspectError::NoClock`] if `today` is `None` and the host date cannot be
/// read — the same three as [`trust_summary`], which this calls.
pub fn bundle_info(root: &Path, today: Option<&str>) -> Result<BundleInfo, InspectError> {
    let today = resolve_today(today)?;
    let bundle = load(root)?;
    let trust = summarise_trust(&bundle, &root.display().to_string(), today);
    let links = link_report(root)?;
    let computations = computation_report(root)?;

    let mut statuses: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
    for concept in bundle.concepts() {
        *statuses.entry(concept.status().to_string()).or_default() += 1;
    }

    Ok(BundleInfo {
        root: root.display().to_string(),
        okf_version: bundle.okf_version().map(ToOwned::to_owned),
        title: bundle_title(&bundle),
        concepts: bundle.concepts().len(),
        trust,
        statuses: statuses.into_iter().collect(),
        links: (links.links, links.broken.len()),
        computations: (computations.computations, computations.incomplete()),
        runtimes: computations.runtimes,
        files: bundle_files(root),
    })
}

/// The bundle's own title, from the `title` of its root `index.md`.
///
/// Read through `Document::parse` — the same parser `Bundle::load` uses — rather
/// than a second reader of the same bytes, so the two cannot disagree about what
/// the file says. `okf-core` exposes the index only as a path, so this re-reads
/// one small file; that is cheap next to the directory walk, and a bundle whose
/// index is unreadable simply has no title here, because `okf validate` is what
/// reports a broken index.
fn bundle_title(bundle: &Bundle) -> Option<String> {
    let path = bundle.index_files().first()?;
    let text = std::fs::read_to_string(path).ok()?;
    let document = okf_core::Document::parse(&text).ok()?;
    document
        .frontmatter
        .title()
        .map(std::borrow::Cow::into_owned)
}