Skip to main content

provable_contracts/schema/
external_corpora.rs

1//! `contracts/external-corpora.yaml` — the ONT-001 declaration of corpora that
2//! live OUTSIDE `contracts/` and are therefore NOT in `pv census`'s cardinality.
3//!
4//! # Why this is a kind and not a contract
5//!
6//! The file has no `metadata:` block, because it is not a theorem about
7//! anything: it is a list of repositories, each pinned to a `head` and each
8//! naming the command that counted it. `pv validate` had one answer for a file
9//! without `metadata:` — ``Failed to parse YAML: missing field `metadata` `` —
10//! and that answer took the 0.68.0 T-2 pre-publish dogfood to NO-GO on release
11//! commit `27f070324`: the `pv-contracts` row validates every
12//! `contracts/**/*.yaml` and this was the one file of 1841 that failed
13//! (PMAT-1098). The three non-fixes were all available and all rejected:
14//! excluding the row's one file, moving the file (`pv census` hard-codes
15//! `<contracts>/external-corpora.yaml`), and pasting a fake `metadata:` block
16//! onto a non-contract. What was missing was the kind, so here it is.
17//!
18//! # One definition of the shape
19//!
20//! [`ExternalCorpus`] used to live in `pv census`'s own module, which made the
21//! declaration's shape a thing two files each knew half of. It lives here now
22//! and `census` imports it, so `pv validate` and `pv census` cannot disagree
23//! about what the file is: [`validate_external_corpora`] deserializes through
24//! the SAME struct the census reads (rule `EXT-CORPORA-009`), which means a
25//! declaration pv calls valid is one the census can read, by construction.
26//!
27//! Deserializing and being valid stay separate questions, exactly as they are
28//! for a [`crate::schema::Contract`]. `ExternalCorpus` keeps `repo`/`ref`/`head`
29//! optional so the census can read a partial declaration and say so; the rules
30//! below then REQUIRE them, because a corpus whose commit is not pinned cannot
31//! be re-counted, and an un-re-countable figure is the thing ONT-001 R-10 says
32//! must not be believed.
33
34use serde::{Deserialize, Serialize};
35use serde_yaml::{Mapping, Value};
36
37use crate::error::{ContractError, Severity, Violation};
38
39/// The `schema:` family this kind owns. A top-level `schema:` string starting
40/// with this prefix IS an external-corpora declaration — including one whose
41/// version is not recognised, which is refused loudly rather than read as
42/// something else (see `EXT-CORPORA-001`).
43pub const SCHEMA_PREFIX: &str = "ont.paiml.dev/external-corpora/";
44
45/// Versions of the family these rules are written against. Adding a version
46/// here is a decision to have READ it: an unlisted version fails closed.
47pub const SUPPORTED_VERSIONS: &[&str] = &["v1alpha1"];
48
49/// Keys a declaration may carry at the top level.
50const TOP_KEYS: &[&str] = &["schema", "corpora"];
51
52/// Keys one `corpora[]` entry may carry. `mark` is the `[V <date>]` verification
53/// stamp the tracked declaration already uses; it is part of the shape, not an
54/// exception to it.
55const ENTRY_KEYS: &[&str] = &[
56    "name",
57    "repo",
58    "ref",
59    "head",
60    "n_files",
61    "mark",
62    "counted_by",
63    "note",
64];
65
66/// Keys every entry must carry, non-empty. `head` is here because a corpus
67/// declared at a moving `ref` alone cannot be re-measured to the same number.
68const ENTRY_REQUIRED: &[&str] = &["name", "repo", "ref", "head"];
69
70/// One `contracts/external-corpora.yaml` entry, copied into the census verbatim.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ExternalCorpus {
73    pub name: String,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub repo: Option<String>,
76    #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
77    pub git_ref: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub head: Option<String>,
80    pub n_files: usize,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub mark: Option<String>,
83    /// The command that produced `n_files`. Never run by the census: a census
84    /// must be reproducible offline, and a network read would make two runs
85    /// disagree.
86    pub counted_by: String,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub note: Option<String>,
89}
90
91/// The whole declaration document.
92#[derive(Debug, Clone, Deserialize)]
93pub struct ExternalCorpora {
94    #[serde(default)]
95    pub schema: Option<String>,
96    #[serde(default)]
97    pub corpora: Vec<ExternalCorpus>,
98}
99
100/// Parse an external-corpora declaration.
101///
102/// # Errors
103///
104/// [`ContractError::Yaml`] if the text is not a declaration of this shape.
105pub fn parse_external_corpora_str(yaml: &str) -> Result<ExternalCorpora, ContractError> {
106    Ok(serde_yaml::from_str(yaml)?)
107}
108
109/// Does `schema` name the external-corpora family (any version)?
110#[must_use]
111pub fn is_external_corpora_schema(schema: &str) -> bool {
112    schema.starts_with(SCHEMA_PREFIX)
113}
114
115fn violation(rule: &str, message: String, location: &str) -> Violation {
116    Violation {
117        severity: Severity::Error,
118        rule: rule.to_string(),
119        message,
120        location: Some(location.to_string()),
121    }
122}
123
124/// Validate an external-corpora declaration (rules `EXT-CORPORA-001..009`).
125#[must_use]
126pub fn validate_external_corpora(yaml: &str) -> Vec<Violation> {
127    let doc: Value = match serde_yaml::from_str(yaml) {
128        Ok(doc) => doc,
129        Err(e) => {
130            return vec![violation(
131                "EXT-CORPORA-001",
132                format!("external-corpora declaration is not YAML: {e}"),
133                "",
134            )]
135        }
136    };
137    let Some(top) = doc.as_mapping() else {
138        return vec![violation(
139            "EXT-CORPORA-001",
140            "external-corpora declaration is not a YAML mapping".to_string(),
141            "",
142        )];
143    };
144    let mut violations = Vec::new();
145    check_schema_version(top, &mut violations);
146    check_unknown_keys(top, TOP_KEYS, "", &mut violations);
147    check_corpora(top, &mut violations);
148    check_census_readable(yaml, &mut violations);
149    violations
150}
151
152/// `EXT-CORPORA-001`: the declaration names its own schema, at a version these
153/// rules were written against. An unknown version is refused, never read as
154/// `v1alpha1`: rules that silently apply to a document they were not written
155/// for are how a schema bump ships unvalidated.
156fn check_schema_version(top: &Mapping, violations: &mut Vec<Violation>) {
157    let Some(Value::String(schema)) = top.get("schema") else {
158        violations.push(violation(
159            "EXT-CORPORA-001",
160            format!("`schema` is missing or not a string — expected {SCHEMA_PREFIX}<version>"),
161            "schema",
162        ));
163        return;
164    };
165    let version = schema.trim_start_matches(SCHEMA_PREFIX);
166    if !SUPPORTED_VERSIONS.contains(&version) {
167        violations.push(violation(
168            "EXT-CORPORA-001",
169            format!(
170                "`schema` {schema:?} is version {version:?}, which these rules were not \
171                 written for — accepted versions are {SUPPORTED_VERSIONS:?}. A newer \
172                 declaration must be read before it is validated, not validated by \
173                 rules that predate it"
174            ),
175            "schema",
176        ));
177    }
178}
179
180/// `EXT-CORPORA-007`: unknown keys are an error, the same fail-closed posture
181/// the other artifact kinds take. A misspelt `n_flies:` would otherwise be
182/// dropped by serde and the corpus counted as 0.
183fn check_unknown_keys(
184    map: &Mapping,
185    allowed: &[&str],
186    prefix: &str,
187    violations: &mut Vec<Violation>,
188) {
189    for key in map.keys() {
190        let Some(key) = key.as_str() else {
191            violations.push(violation(
192                "EXT-CORPORA-007",
193                format!("{prefix}key {key:?} is not a string"),
194                prefix,
195            ));
196            continue;
197        };
198        if !allowed.contains(&key) {
199            violations.push(violation(
200                "EXT-CORPORA-007",
201                format!(
202                    "unknown key `{prefix}{key}` — an external-corpora declaration \
203                     carries only {allowed:?}, and a key nothing reads is a figure \
204                     nobody is keeping honest"
205                ),
206                &format!("{prefix}{key}"),
207            ));
208        }
209    }
210}
211
212/// `EXT-CORPORA-002`: `corpora` is a non-empty list. An empty declaration is a
213/// file that claims to declare and declares nothing.
214fn check_corpora(top: &Mapping, violations: &mut Vec<Violation>) {
215    let Some(Value::Sequence(entries)) = top.get("corpora") else {
216        violations.push(violation(
217            "EXT-CORPORA-002",
218            "`corpora` is missing or not a list".to_string(),
219            "corpora",
220        ));
221        return;
222    };
223    if entries.is_empty() {
224        violations.push(violation(
225            "EXT-CORPORA-002",
226            "`corpora` is empty — a declaration that declares nothing is a file, not a \
227             declaration; delete it instead"
228                .to_string(),
229            "corpora",
230        ));
231        return;
232    }
233    let mut seen: Vec<String> = Vec::new();
234    for (i, entry) in entries.iter().enumerate() {
235        check_entry(entry, i, &mut seen, violations);
236    }
237}
238
239fn check_entry(entry: &Value, i: usize, seen: &mut Vec<String>, violations: &mut Vec<Violation>) {
240    let prefix = format!("corpora[{i}].");
241    let Some(map) = entry.as_mapping() else {
242        violations.push(violation(
243            "EXT-CORPORA-003",
244            format!("corpora[{i}] is not a mapping"),
245            &prefix,
246        ));
247        return;
248    };
249    check_unknown_keys(map, ENTRY_KEYS, &prefix, violations);
250    check_entry_required(map, &prefix, violations);
251    check_repo(map, &prefix, violations);
252    check_head(map, &prefix, violations);
253    check_entry_optional(map, &prefix, violations);
254    check_duplicate_name(map, &prefix, seen, violations);
255}
256
257/// `EXT-CORPORA-003`: required fields present, non-null and non-empty.
258fn check_entry_required(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
259    for field in ENTRY_REQUIRED {
260        let why = match map.get(*field) {
261            None => "missing",
262            Some(Value::Null) => "null",
263            Some(Value::String(s)) if s.trim().is_empty() => "an empty string",
264            Some(Value::String(_)) => continue,
265            Some(_) => "not a string",
266        };
267        violations.push(violation(
268            "EXT-CORPORA-003",
269            format!(
270                "required field `{prefix}{field}` is {why} — without it the corpus \
271                 cannot be re-counted, and ONT-001 R-10 declares a figure so that it \
272                 can be re-measured rather than believed"
273            ),
274            &format!("{prefix}{field}"),
275        ));
276    }
277}
278
279/// `EXT-CORPORA-004`: `repo` is `owner/name` — what `gh api repos/<repo>` takes.
280fn check_repo(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
281    let Some(Value::String(repo)) = map.get("repo") else {
282        return;
283    };
284    let parts: Vec<&str> = repo.split('/').collect();
285    if parts.len() == 2 && parts.iter().all(|p| !p.trim().is_empty()) {
286        return;
287    }
288    violations.push(violation(
289        "EXT-CORPORA-004",
290        format!(
291            "`{prefix}repo` {repo:?} is not owner/name — it is what `gh api repos/<repo>` \
292             takes, and any other spelling names no repository"
293        ),
294        &format!("{prefix}repo"),
295    ));
296}
297
298/// `EXT-CORPORA-005`: `head` is a 7–40 character hex commit id. A branch name
299/// here would make the pin move, which is the whole point of having it.
300fn check_head(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
301    let Some(Value::String(head)) = map.get("head") else {
302        return;
303    };
304    let ok = (7..=40).contains(&head.len()) && head.chars().all(|c| c.is_ascii_hexdigit());
305    if !ok {
306        violations.push(violation(
307            "EXT-CORPORA-005",
308            format!(
309                "`{prefix}head` {head:?} is not a 7-40 character hex commit id — a corpus \
310                 pinned to anything that can move cannot be re-counted to the same number"
311            ),
312            &format!("{prefix}head"),
313        ));
314    }
315}
316
317/// `EXT-CORPORA-006`: the optional fields, when present, have their declared
318/// types — `n_files` an integer >= 0, the rest strings.
319fn check_entry_optional(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
320    if let Some(value) = map.get("n_files") {
321        if value.as_u64().is_none() {
322            violations.push(violation(
323                "EXT-CORPORA-006",
324                format!(
325                    "`{prefix}n_files` must be an integer >= 0, got {value:?} — it is a \
326                     file count, and the census copies it verbatim"
327                ),
328                &format!("{prefix}n_files"),
329            ));
330        }
331    }
332    for field in ["counted_by", "mark", "note"] {
333        match map.get(field) {
334            None | Some(Value::String(_)) => {}
335            Some(value) => violations.push(violation(
336                "EXT-CORPORA-006",
337                format!("`{prefix}{field}` must be a string, got {value:?}"),
338                &format!("{prefix}{field}"),
339            )),
340        }
341    }
342}
343
344/// `EXT-CORPORA-008`: two entries with one name. The census sorts by `name` and
345/// the reader keys off it, so a duplicate makes "which one is 397?" unanswerable.
346fn check_duplicate_name(
347    map: &Mapping,
348    prefix: &str,
349    seen: &mut Vec<String>,
350    violations: &mut Vec<Violation>,
351) {
352    let Some(Value::String(name)) = map.get("name") else {
353        return;
354    };
355    if seen.iter().any(|s| s == name) {
356        violations.push(violation(
357            "EXT-CORPORA-008",
358            format!(
359                "duplicate corpus name {name:?} — the census sorts and reports by name, \
360                 so two entries sharing one make the figure unattributable"
361            ),
362            &format!("{prefix}name"),
363        ));
364    } else {
365        seen.push(name.clone());
366    }
367}
368
369/// `EXT-CORPORA-009`: the declaration deserializes into [`ExternalCorpora`] —
370/// the struct `pv census` reads.
371///
372/// This is the binding between the two commands, and it is a rule rather than a
373/// convention on purpose: without it, `pv validate` would be checking a shape it
374/// describes and the census a shape it parses, which is the "two implementations,
375/// each green against its own copy" defect this module exists to close.
376fn check_census_readable(yaml: &str, violations: &mut Vec<Violation>) {
377    if let Err(e) = parse_external_corpora_str(yaml) {
378        violations.push(violation(
379            "EXT-CORPORA-009",
380            format!(
381                "the declaration does not deserialize into the struct `pv census` reads: \
382                 {e} — pv validate would be passing a file the census cannot count"
383            ),
384            "",
385        ));
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    include!("external_corpora_tests.rs");
392}