Skip to main content

provable_contracts/ontology/extract/json/
github.rs

1//! ONT-4f (aprender#4330) — GitHub entities as COMMITTED SNAPSHOTS, read by `extract:json`.
2//!
3//! Σ declares four snapshot entity types — `repo`, `issue`, `pull-request`, `milestone` — each on the `json`
4//! extractor with a `vocabulary: {prefix, root_class, version}`. This module is the part of `extract:json` that
5//! reads them: every `evidence/github/<type>/<ref-slug>.json` becomes one root node typed the vocabulary's
6//! `root_class` (and `prov:Entity`), through the SAME [`super::node`] a `json` contract's document goes through,
7//! so a snapshot's scalar keys are `<prefix>:<key>` literals exactly as they are there. It is a submodule of
8//! `json.rs`, not a new extractor: Σ's `extractors[]` gains nothing.
9//!
10//! **No live API.** The extractor reads committed files and nothing else; a snapshot is refreshed by committing a
11//! new one, and the version slot of its `ref` says which moment it records:
12//!
13//! - `repo`: `<owner>/<repo>@<sha>` — the 40-hex commit, which must equal the snapshot's own `sha`;
14//! - `issue`, `pull-request`, `milestone`: `<owner>/<repo>#<n>@<updatedAt>`, which must equal its `updatedAt`.
15//!
16//! **Refused, by file, as PV-ONT-012 (Fail — the corpus is wrong, not the declaration):** a missing or malformed
17//! `ref`; a version that disagrees with the snapshot's own field (naming BOTH values); an identity that disagrees
18//! with the snapshot's `nameWithOwner` / `repo` / `number`; a file name that is not the ref's slug; a `state:
19//! merged` with no `mergedAt` (naming the field — a merge with no time is a claim with no witness); a nested
20//! object (the vocabulary maps none); two snapshots of one identity; a directory Σ declares no type for; a file
21//! that is not `.json`. A refused file emits nothing.
22//!
23//! **`resolves:` between snapshots only.** `pr:baseRepo resolves: repo` and `issue:milestone resolves:
24//! milestone` ([`RESOLVES`], tied to `contracts/github-entities-v1.yaml` by a test) are joined here: a value that
25//! names a TRACKED snapshot — by identity (`owner/repo`, `owner/repo#n`) or by its full ref — becomes an IRI edge
26//! to that node, so the shape's `class:` sees a typed target. A value that names nothing tracked is materialized on
27//! `<prefix>:<key>Unresolved`, which the shape holds at `maxCount: 0`: it FAILS CLOSED. It is never `Unknown`, and
28//! it is never looked up anywhere else.
29
30use std::collections::{BTreeMap, BTreeSet};
31use std::path::Path;
32
33use super::{node, Vocabulary};
34use crate::ontology::rdf::{iri, Graph, Term};
35use crate::ontology::shapes::expand;
36use crate::ontology::sigma::Sigma;
37
38/// Where the snapshots live, relative to the repo root: one directory per Σ snapshot entity type.
39pub const EVIDENCE_DIR: &str = "evidence/github";
40
41/// `(entity type, key, entity type the value resolves to)`. The contract's `resolves:` declarations must equal
42/// this table ([`tests::the_resolves_table_is_what_the_contract_declares`]); a second hand-kept list with nothing
43/// tying it to the first is bashrs#266's shape.
44pub const RESOLVES: [(&str, &str, &str); 2] = [
45    ("pull-request", "baseRepo", "repo"),
46    ("issue", "milestone", "milestone"),
47];
48
49/// The suffix of the predicate an unresolved reference is materialized on (`pr:baseRepoUnresolved`).
50pub const UNRESOLVED_SUFFIX: &str = "Unresolved";
51
52/// One Σ snapshot entity type: its name (the directory), and its vocabulary.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SnapshotType {
55    pub name: String,
56    pub prefix: String,
57    pub root_class: String,
58    /// The snapshot's own field the ref's `@<version>` slot must equal.
59    pub version: String,
60}
61
62impl SnapshotType {
63    /// `repo` is the one type whose ref names no number: a repository is `<owner>/<repo>`, everything inside it
64    /// is `<owner>/<repo>#<n>` (ONT-001 §5 ONT-4f).
65    fn numbered(&self) -> bool {
66        self.name != "repo"
67    }
68}
69
70/// The snapshot entity types Σ declares: implemented, on the `json` extractor, carrying a `vocabulary`.
71#[must_use]
72pub fn snapshot_types(sigma: &Sigma) -> Vec<SnapshotType> {
73    sigma
74        .entity_types
75        .iter()
76        .filter(|e| e.implemented && e.extractor == "json")
77        .filter_map(|e| {
78            e.vocabulary.as_ref().map(|v| SnapshotType {
79                name: e.name.clone(),
80                prefix: v.prefix.clone(),
81                root_class: v.root_class.clone(),
82                version: v.version.clone(),
83            })
84        })
85        .collect()
86}
87
88/// A file this extractor refused, and why. Reported as PV-ONT-012.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Refusal {
91    pub file: String,
92    pub what: String,
93}
94
95impl std::fmt::Display for Refusal {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        write!(f, "{}: {}", self.file, self.what)
98    }
99}
100
101/// What one walk read.
102#[derive(Debug, Clone, Default, PartialEq, Eq)]
103pub struct GithubStats {
104    /// Focus nodes emitted per Σ snapshot type — every declared type present, so an empty directory reads 0,
105    /// never an absent key.
106    pub by_type: BTreeMap<String, usize>,
107    pub errors: Vec<Refusal>,
108    /// References materialized on `<prefix>:<key>Unresolved`.
109    pub unresolved: usize,
110}
111
112/// A parsed `ref`: `<owner>/<repo>[#<n>]@<version>`.
113#[derive(Debug, Clone, PartialEq, Eq)]
114struct Ref {
115    owner: String,
116    repo: String,
117    number: Option<u64>,
118    version: String,
119}
120
121impl Ref {
122    fn identity(&self) -> String {
123        match self.number {
124            Some(n) => format!("{}/{}#{n}", self.owner, self.repo),
125            None => format!("{}/{}", self.owner, self.repo),
126        }
127    }
128    fn slug(&self) -> String {
129        match self.number {
130            Some(n) => format!("{}__{}__{n}", self.owner, self.repo),
131            None => format!("{}__{}", self.owner, self.repo),
132        }
133    }
134}
135
136fn name_ok(s: &str) -> bool {
137    !s.is_empty()
138        && s.chars()
139            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
140}
141
142fn parse_ref(t: &SnapshotType, s: &str) -> Result<Ref, String> {
143    let form = if t.numbered() {
144        "<owner>/<repo>#<n>@<updatedAt>"
145    } else {
146        "<owner>/<repo>@<sha>"
147    };
148    let bad = || format!("`ref` `{s}` is not `{form}`");
149    let (who, version) = s.split_once('@').ok_or_else(bad)?;
150    let (path, number) = match (t.numbered(), who.split_once('#')) {
151        (true, Some((p, n))) => (p, Some(n.parse::<u64>().map_err(|_| bad())?)),
152        (false, None) => (who, None),
153        _ => return Err(bad()),
154    };
155    let (owner, repo) = path.split_once('/').ok_or_else(bad)?;
156    if !name_ok(owner)
157        || !name_ok(repo)
158        || version.is_empty()
159        || version.contains(char::is_whitespace)
160    {
161        return Err(bad());
162    }
163    let is_sha = version.len() == 40
164        && version
165            .bytes()
166            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
167    if !t.numbered() && !is_sha {
168        return Err(format!(
169            "`ref` `{s}`: `@{version}` is not a 40-hex commit sha"
170        ));
171    }
172    Ok(Ref {
173        owner: owner.to_string(),
174        repo: repo.to_string(),
175        number,
176        version: version.to_string(),
177    })
178}
179
180fn text_of(v: Option<&serde_json::Value>) -> Option<String> {
181    match v? {
182        serde_json::Value::String(s) => Some(s.clone()),
183        serde_json::Value::Number(n) => Some(n.to_string()),
184        _ => None,
185    }
186}
187
188/// Every check on one snapshot that needs nothing but the snapshot. `Ok` is the parsed ref and the object.
189fn check(
190    t: &SnapshotType,
191    file: &str,
192    text: &str,
193) -> Result<(Ref, serde_json::Map<String, serde_json::Value>), String> {
194    let value: serde_json::Value =
195        serde_json::from_str(text).map_err(|e| format!("not JSON: {e}"))?;
196    let serde_json::Value::Object(map) = value else {
197        return Err("the root is not a JSON object".into());
198    };
199    let r = match map.get("ref") {
200        Some(serde_json::Value::String(s)) => parse_ref(t, s)?,
201        _ => return Err("no string `ref` — a snapshot names the moment it records".into()),
202    };
203    check_version(t, &map, &r)?;
204    check_identity(&map, &r, file)?;
205    check_fields(t, &map)?;
206    Ok((r, map))
207}
208
209/// The ref's `@<version>` agrees with the snapshot's own version field.
210fn check_version(
211    t: &SnapshotType,
212    map: &serde_json::Map<String, serde_json::Value>,
213    r: &Ref,
214) -> Result<(), String> {
215    match text_of(map.get(&t.version)) {
216        None => Err(format!(
217            "no `{}` field — the ref's `@{}` has nothing to agree with",
218            t.version, r.version
219        )),
220        Some(recorded) if recorded != r.version => Err(format!(
221            "ref version `{}` disagrees with the snapshot's own `{}` `{recorded}`",
222            r.version, t.version
223        )),
224        Some(_) => Ok(()),
225    }
226}
227
228/// The ref's owner/repo and number agree with the snapshot's own fields, and the file is named by its slug.
229fn check_identity(
230    map: &serde_json::Map<String, serde_json::Value>,
231    r: &Ref,
232    file: &str,
233) -> Result<(), String> {
234    let owner_repo = format!("{}/{}", r.owner, r.repo);
235    for key in ["nameWithOwner", "repo"] {
236        if let Some(v) = text_of(map.get(key)).filter(|v| *v != owner_repo) {
237            return Err(format!(
238                "ref names `{owner_repo}` but the snapshot's own `{key}` is `{v}`"
239            ));
240        }
241    }
242    if let (Some(n), Some(v)) = (r.number, text_of(map.get("number"))) {
243        if v != n.to_string() {
244            return Err(format!(
245                "ref names #{n} but the snapshot's own `number` is `{v}`"
246            ));
247        }
248    }
249    let stem = Path::new(file)
250        .file_stem()
251        .and_then(|s| s.to_str())
252        .unwrap_or_default();
253    if stem != r.slug() {
254        return Err(format!(
255            "file name `{stem}.json` is not the ref's slug `{}.json`",
256            r.slug()
257        ));
258    }
259    Ok(())
260}
261
262/// A merge carries its time, and every reference key names its target by string.
263fn check_fields(
264    t: &SnapshotType,
265    map: &serde_json::Map<String, serde_json::Value>,
266) -> Result<(), String> {
267    let merged = text_of(map.get("state")).is_some_and(|s| s.eq_ignore_ascii_case("merged"));
268    let merged_at = text_of(map.get("mergedAt")).is_some_and(|s| !s.trim().is_empty());
269    if merged && !merged_at {
270        return Err(
271            "`state` is merged but `mergedAt` is absent — a merge with no time has no witness"
272                .into(),
273        );
274    }
275    for (ty, key, _) in RESOLVES {
276        if ty != t.name {
277            continue;
278        }
279        match map.get(key) {
280            None | Some(serde_json::Value::Null | serde_json::Value::String(_)) => {}
281            Some(other) => {
282                return Err(format!(
283                    "`{key}` must name a snapshot by string, found `{other}`"
284                ))
285            }
286        }
287    }
288    Ok(())
289}
290
291/// One accepted snapshot, waiting for the resolve pass.
292struct Accepted {
293    ty: usize,
294    file: String,
295    r: Ref,
296    map: serde_json::Map<String, serde_json::Value>,
297}
298
299/// The walk: `<root>/evidence/github/<type>/*.json`, in byte order, for every type in `types`. Anything else in
300/// the directory is refused, never skipped. No directory at all is an empty corpus (every type reads 0).
301pub fn extract(root: &Path, types: &[SnapshotType], g: &mut Graph) -> GithubStats {
302    let dir = root.join(EVIDENCE_DIR);
303    let mut files: Vec<(String, String, Result<String, String>)> = Vec::new();
304    let mut stray: Vec<Refusal> = Vec::new();
305    let mut entries: Vec<std::path::PathBuf> = std::fs::read_dir(&dir)
306        .map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect())
307        .unwrap_or_default();
308    entries.sort();
309    for sub in entries {
310        let name = sub
311            .file_name()
312            .and_then(|n| n.to_str())
313            .unwrap_or_default()
314            .to_string();
315        let rel = format!("{EVIDENCE_DIR}/{name}");
316        if !sub.is_dir() || !types.iter().any(|t| t.name == name) {
317            stray.push(Refusal {
318                file: rel,
319                what: format!(
320                    "Σ declares no snapshot entity type `{name}` — {EVIDENCE_DIR}/ holds one directory per type"
321                ),
322            });
323            continue;
324        }
325        let mut inner: Vec<std::path::PathBuf> = std::fs::read_dir(&sub)
326            .map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect())
327            .unwrap_or_default();
328        inner.sort();
329        for f in inner {
330            let fname = f
331                .file_name()
332                .and_then(|n| n.to_str())
333                .unwrap_or_default()
334                .to_string();
335            let text = if f.extension().and_then(|e| e.to_str()) == Some("json") && f.is_file() {
336                std::fs::read_to_string(&f).map_err(|e| format!("unreadable: {e}"))
337            } else {
338                Err("not a `.json` snapshot".into())
339            };
340            files.push((name.clone(), format!("{rel}/{fname}"), text));
341        }
342    }
343    let mut stats = extract_files(&files, types, g);
344    stray.append(&mut stats.errors);
345    stats.errors = stray;
346    stats
347}
348
349/// Everything after the reads: `(type, file, text)` as nodes. Shared by [`extract`] and the positive controls, so
350/// a control exercises the code the gate runs.
351fn extract_files(
352    files: &[(String, String, Result<String, String>)],
353    types: &[SnapshotType],
354    g: &mut Graph,
355) -> GithubStats {
356    let mut stats = GithubStats {
357        by_type: types.iter().map(|t| (t.name.clone(), 0)).collect(),
358        ..GithubStats::default()
359    };
360    let accepted = accept(files, types, &mut stats);
361    let tracked = tracked_refs(&accepted, types);
362    for a in accepted {
363        emit(a, types, &tracked, g, &mut stats);
364    }
365    stats
366}
367
368/// The per-snapshot checks and the one-identity-one-moment rule; every refusal lands on `stats.errors`.
369fn accept(
370    files: &[(String, String, Result<String, String>)],
371    types: &[SnapshotType],
372    stats: &mut GithubStats,
373) -> Vec<Accepted> {
374    let mut accepted: Vec<Accepted> = Vec::new();
375    let mut seen: BTreeSet<(usize, String)> = BTreeSet::new();
376    for (ty_name, file, text) in files {
377        let refuse = |what: String| Refusal {
378            file: file.clone(),
379            what,
380        };
381        let Some(ty) = types.iter().position(|t| &t.name == ty_name) else {
382            stats.errors.push(refuse(format!(
383                "Σ declares no snapshot entity type `{ty_name}`"
384            )));
385            continue;
386        };
387        let checked = text
388            .as_ref()
389            .map_err(Clone::clone)
390            .and_then(|text| check(&types[ty], file, text));
391        let (r, map) = match checked {
392            Err(what) => {
393                stats.errors.push(refuse(what));
394                continue;
395            }
396            Ok(ok) => ok,
397        };
398        if !seen.insert((ty, r.identity())) {
399            stats.errors.push(refuse(format!(
400                "a second snapshot of `{}` — one identity, one tracked moment",
401                r.identity()
402            )));
403            continue;
404        }
405        accepted.push(Accepted {
406            ty,
407            file: file.clone(),
408            r,
409            map,
410        });
411    }
412    accepted
413}
414
415/// identity → full ref, per type: what a reference may resolve to.
416fn tracked_refs<'t>(
417    accepted: &[Accepted],
418    types: &'t [SnapshotType],
419) -> BTreeMap<&'t str, BTreeMap<String, String>> {
420    let mut tracked: BTreeMap<&str, BTreeMap<String, String>> = BTreeMap::new();
421    for a in accepted {
422        let full = format!("{}@{}", a.r.identity(), a.r.version);
423        let per = tracked.entry(types[a.ty].name.as_str()).or_default();
424        per.insert(a.r.identity(), full.clone());
425        per.insert(full.clone(), full);
426    }
427    tracked
428}
429
430/// One accepted snapshot as a typed node plus its reference edges, staged so a refused node leaves no triple.
431fn emit(
432    a: Accepted,
433    types: &[SnapshotType],
434    tracked: &BTreeMap<&str, BTreeMap<String, String>>,
435    g: &mut Graph,
436    stats: &mut GithubStats,
437) {
438    let t = &types[a.ty];
439    let mut map = a.map;
440    let mut edges: Vec<(String, String, Option<String>)> = Vec::new();
441    for (ty, key, target) in RESOLVES {
442        if ty != t.name {
443            continue;
444        }
445        if let Some(serde_json::Value::String(v)) = map.remove(key) {
446            let hit = tracked.get(target).and_then(|m| m.get(&v)).cloned();
447            edges.push((key.to_string(), v, hit));
448        } else {
449            map.remove(key);
450        }
451    }
452    let vocab = Vocabulary {
453        prefix: t.prefix.clone(),
454        root_class: t.root_class.clone(),
455        nested: Vec::new(),
456    };
457    let id = format!("{}@{}", a.r.identity(), a.r.version);
458    let mut staged = Graph::new();
459    if let Err(e) = node(
460        &mut staged,
461        &a.file,
462        &id,
463        &t.root_class,
464        true,
465        &serde_json::Value::Object(map),
466        &vocab,
467    ) {
468        stats.errors.push(Refusal {
469            file: a.file,
470            what: e.to_string(),
471        });
472        return;
473    }
474    let s = iri(&t.prefix, &id);
475    for (key, value, hit) in edges {
476        insert_edge(&mut staged, types, t, &s, &key, value, hit, stats);
477    }
478    g.extend(&staged);
479    *stats.by_type.entry(t.name.clone()).or_default() += 1;
480}
481
482/// A resolved reference is an IRI edge to the target's node; an unresolved one is `<key>Unresolved`, counted.
483#[allow(clippy::too_many_arguments)]
484fn insert_edge(
485    staged: &mut Graph,
486    types: &[SnapshotType],
487    t: &SnapshotType,
488    s: &str,
489    key: &str,
490    value: String,
491    hit: Option<String>,
492    stats: &mut GithubStats,
493) {
494    let target_ty = RESOLVES
495        .iter()
496        .find(|(ty, k, _)| *ty == t.name && *k == key)
497        .map(|(_, _, target)| *target)
498        .unwrap_or_default();
499    match (hit, types.iter().find(|x| x.name == target_ty)) {
500        (Some(full), Some(tt)) => staged.insert(
501            s.to_string(),
502            expand(&format!("{}:{key}", t.prefix)),
503            Term::iri(iri(&tt.prefix, &full)),
504        ),
505        _ => {
506            stats.unresolved += 1;
507            staged.insert(
508                s.to_string(),
509                expand(&format!("{}:{key}{UNRESOLVED_SUFFIX}", t.prefix)),
510                Term::string(value),
511            );
512        }
513    }
514}
515
516/// The types as Σ declares them — the controls' fixture, so they run without reading `contracts/`.
517fn control_types() -> Vec<SnapshotType> {
518    [
519        ("repo", "repo", "ont:Repo", "sha"),
520        ("issue", "issue", "ont:Issue", "updatedAt"),
521        ("pull-request", "pr", "ont:PullRequest", "updatedAt"),
522        ("milestone", "milestone", "ont:Milestone", "updatedAt"),
523    ]
524    .into_iter()
525    .map(|(n, p, c, v)| SnapshotType {
526        name: n.into(),
527        prefix: p.into(),
528        root_class: c.into(),
529        version: v.into(),
530    })
531    .collect()
532}
533
534const PC_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
535const PC_TS: &str = "2026-09-24T00:00:00Z";
536
537fn pc_repo(sha: &str) -> (String, String, Result<String, String>) {
538    (
539        "repo".into(),
540        "evidence/github/repo/o__r.json".into(),
541        Ok(format!(
542            r#"{{"ref":"o/r@{PC_SHA}","nameWithOwner":"o/r","sha":"{sha}"}}"#
543        )),
544    )
545}
546
547fn pc_numbered(
548    ty: &str,
549    n: u64,
550    extra: &str,
551    updated: &str,
552) -> (String, String, Result<String, String>) {
553    (
554        ty.into(),
555        format!("evidence/github/{ty}/o__r__{n}.json"),
556        Ok(format!(
557            r#"{{"ref":"o/r#{n}@{PC_TS}","repo":"o/r","number":{n},"updatedAt":"{updated}"{extra}}}"#
558        )),
559    )
560}
561
562fn pc_run(files: &[(String, String, Result<String, String>)]) -> (Graph, GithubStats) {
563    let mut g = Graph::new();
564    let stats = extract_files(files, &control_types(), &mut g);
565    (g, stats)
566}
567
568fn refused_saying(stats: &GithubStats, needle: &str) -> bool {
569    stats.errors.len() == 1 && stats.errors[0].what.contains(needle)
570}
571
572/// The positive control for one Σ snapshot type (R-3, drawn by the shapes gate every run as `pc_extract.<type>`):
573/// the conforming snapshot extracts and the planted defect is refused — or, for `issue`, materialized as
574/// unresolved — by name. `false` for a type this module has no control for.
575#[must_use]
576pub fn positive_control(entity_type: &str) -> bool {
577    match entity_type {
578        // repo: the version slot is checked against the snapshot's own sha, naming both
579        "repo" => {
580            let (_, ok) = pc_run(&[pc_repo(PC_SHA)]);
581            let (_, bad) = pc_run(&[pc_repo("ffffffffffffffffffffffffffffffffffffffff")]);
582            ok.errors.is_empty() && ok.by_type["repo"] == 1 && refused_saying(&bad, "disagrees")
583        }
584        // milestone: the version slot is checked against updatedAt
585        "milestone" => {
586            let (_, ok) = pc_run(&[pc_numbered("milestone", 3, "", PC_TS)]);
587            let (_, bad) = pc_run(&[pc_numbered("milestone", 3, "", "2020-01-01T00:00:00Z")]);
588            ok.errors.is_empty()
589                && ok.by_type["milestone"] == 1
590                && refused_saying(&bad, "disagrees")
591        }
592        // pull-request: merged with no mergedAt is refused naming the field
593        "pull-request" => {
594            let (_, ok) = pc_run(&[pc_numbered(
595                "pull-request",
596                7,
597                r#","state":"MERGED","mergedAt":"2026-09-23T00:00:00Z""#,
598                PC_TS,
599            )]);
600            let (_, bad) = pc_run(&[pc_numbered(
601                "pull-request",
602                7,
603                r#","state":"MERGED""#,
604                PC_TS,
605            )]);
606            ok.errors.is_empty()
607                && ok.by_type["pull-request"] == 1
608                && refused_saying(&bad, "`mergedAt`")
609        }
610        // issue: a tracked milestone is an IRI edge, an untracked one is materialized unresolved
611        "issue" => {
612            let issue = pc_numbered("issue", 9, r#","milestone":"o/r#3""#, PC_TS);
613            let (g, ok) = pc_run(&[issue.clone(), pc_numbered("milestone", 3, "", PC_TS)]);
614            let s = iri("issue", &format!("o/r#9@{PC_TS}"));
615            let edge = g
616                .objects(&s, &expand("issue:milestone"))
617                .first()
618                .and_then(|t| t.as_iri())
619                == Some(iri("milestone", &format!("o/r#3@{PC_TS}")).as_str());
620            let (g2, bad) = pc_run(&[issue]);
621            let unresolved = !g2
622                .objects(&s, &expand(&format!("issue:milestone{UNRESOLVED_SUFFIX}")))
623                .is_empty();
624            ok.errors.is_empty() && ok.unresolved == 0 && edge && bad.unresolved == 1 && unresolved
625        }
626        _ => false,
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::ontology::rdf::{PROV_ENTITY, RDF_TYPE};
634
635    fn types() -> Vec<SnapshotType> {
636        control_types()
637    }
638
639    fn run(files: &[(&str, &str, &str)]) -> (Graph, GithubStats) {
640        let files: Vec<_> = files
641            .iter()
642            .map(|(t, f, x)| ((*t).to_string(), (*f).to_string(), Ok((*x).to_string())))
643            .collect();
644        let mut g = Graph::new();
645        let s = extract_files(&files, &types(), &mut g);
646        (g, s)
647    }
648
649    const SHA: &str = "aa7c6ef03ee7b7f8d8dc09dc393e97619952d95f";
650    const REPO: &str = r#"{"ref":"paiml/aprender@aa7c6ef03ee7b7f8d8dc09dc393e97619952d95f","nameWithOwner":"paiml/aprender","sha":"aa7c6ef03ee7b7f8d8dc09dc393e97619952d95f"}"#;
651    const MILESTONE: &str = r#"{"ref":"paiml/aprender#3@2026-09-24T07:03:22Z","repo":"paiml/aprender","number":3,"state":"CLOSED","updatedAt":"2026-09-24T07:03:22Z"}"#;
652
653    /// #4587: `refused_saying` is true only for exactly one refusal that names the needle.
654    #[test]
655    fn refused_saying_needs_exactly_one_error_naming_the_needle() {
656        let refusal = |what: &str| Refusal {
657            file: "f".to_string(),
658            what: what.to_string(),
659        };
660        let with = |errs: Vec<Refusal>| GithubStats {
661            errors: errs,
662            ..GithubStats::default()
663        };
664        assert!(!refused_saying(&with(vec![]), "disagrees"));
665        assert!(!refused_saying(&with(vec![refusal("other")]), "disagrees"));
666        assert!(!refused_saying(
667            &with(vec![refusal("disagrees"), refusal("disagrees")]),
668            "disagrees"
669        ));
670        assert!(refused_saying(
671            &with(vec![refusal("x disagrees y")]),
672            "disagrees"
673        ));
674    }
675
676    fn only_error(s: &GithubStats) -> &str {
677        assert_eq!(s.errors.len(), 1, "{:?}", s.errors);
678        &s.errors[0].what
679    }
680
681    // ── the three Mutations ONT-001 §5 ONT-4f names ─────────────────────────────────────────────────────────
682
683    #[test]
684    fn mutation_a_merged_pull_request_with_no_merged_at_is_refused_naming_the_field() {
685        let (g, s) = run(&[(
686            "pull-request",
687            "evidence/github/pull-request/paiml__aprender__3706.json",
688            r#"{"ref":"paiml/aprender#3706@2026-09-21T16:17:56Z","repo":"paiml/aprender","number":3706,"state":"MERGED","updatedAt":"2026-09-21T16:17:56Z"}"#,
689        )]);
690        assert!(only_error(&s).contains("`mergedAt`"), "{:?}", s.errors);
691        assert_eq!(s.by_type["pull-request"], 0);
692        assert!(g.is_empty(), "a refused snapshot emits nothing");
693        // lower-case and an empty-string mergedAt are the same claim
694        let (_, s) = run(&[(
695            "pull-request",
696            "evidence/github/pull-request/o__r__1.json",
697            r#"{"ref":"o/r#1@t","updatedAt":"t","state":"merged","mergedAt":""}"#,
698        )]);
699        assert!(only_error(&s).contains("`mergedAt`"), "{:?}", s.errors);
700    }
701
702    #[test]
703    fn mutation_an_issue_naming_an_untracked_milestone_fails_closed_never_unknown() {
704        let issue = r#"{"ref":"paiml/aprender#3022@2026-09-10T03:13:40Z","repo":"paiml/aprender","number":3022,"updatedAt":"2026-09-10T03:13:40Z","milestone":"paiml/aprender#99"}"#;
705        let (g, s) = run(&[
706            (
707                "issue",
708                "evidence/github/issue/paiml__aprender__3022.json",
709                issue,
710            ),
711            (
712                "milestone",
713                "evidence/github/milestone/paiml__aprender__3.json",
714                MILESTONE,
715            ),
716        ]);
717        assert!(s.errors.is_empty(), "{:?}", s.errors);
718        assert_eq!(s.unresolved, 1);
719        let subj = iri("issue", "paiml/aprender#3022@2026-09-10T03:13:40Z");
720        assert_eq!(
721            g.objects(&subj, &expand("issue:milestoneUnresolved")),
722            vec![&Term::string("paiml/aprender#99")],
723            "the unresolved name is materialized where the shape's maxCount 0 sees it"
724        );
725        assert!(
726            g.objects(&subj, &expand("issue:milestone")).is_empty(),
727            "no edge to a node that does not exist, and no literal the class check would misread"
728        );
729    }
730
731    #[test]
732    fn mutation_a_repo_whose_sha_disagrees_with_its_ref_is_refused_naming_both() {
733        let other = "0000000000000000000000000000000000000000";
734        let bad = REPO.replacen(
735            &format!(r#""sha":"{SHA}""#),
736            &format!(r#""sha":"{other}""#),
737            1,
738        );
739        assert_ne!(bad, REPO, "the plant must change the snapshot");
740        let (g, s) = run(&[("repo", "evidence/github/repo/paiml__aprender.json", &bad)]);
741        let e = only_error(&s);
742        assert!(
743            e.contains("disagrees") && e.contains(SHA) && e.contains(other),
744            "{e}"
745        );
746        assert!(g.is_empty());
747    }
748
749    // ── the rest of the contract ───────────────────────────────────────────────────────────────────────────
750
751    #[test]
752    fn a_tracked_reference_is_an_iri_edge_to_the_typed_node_by_identity_or_full_ref() {
753        for name in ["paiml/aprender", &format!("paiml/aprender@{SHA}")] {
754            let pr = format!(
755                r#"{{"ref":"paiml/aprender#1@t","repo":"paiml/aprender","number":1,"updatedAt":"t","state":"OPEN","baseRepo":"{name}"}}"#
756            );
757            let (g, s) = run(&[
758                (
759                    "pull-request",
760                    "evidence/github/pull-request/paiml__aprender__1.json",
761                    &pr,
762                ),
763                ("repo", "evidence/github/repo/paiml__aprender.json", REPO),
764            ]);
765            assert!(s.errors.is_empty(), "{:?}", s.errors);
766            assert_eq!(s.unresolved, 0);
767            let target = iri("repo", &format!("paiml/aprender@{SHA}"));
768            let subj = iri("pr", "paiml/aprender#1@t");
769            assert_eq!(
770                g.objects(&subj, &expand("pr:baseRepo")),
771                vec![&Term::iri(target.clone())],
772                "{name}"
773            );
774            assert!(g
775                .instances_of(&expand("ont:Repo"))
776                .contains(&target.as_str()));
777            assert!(g
778                .instances_of(&expand("ont:PullRequest"))
779                .contains(&subj.as_str()));
780            assert!(g.instances_of(PROV_ENTITY).contains(&subj.as_str()));
781        }
782    }
783
784    #[test]
785    fn a_full_ref_at_another_moment_does_not_resolve() {
786        let pr = r#"{"ref":"paiml/aprender#1@t","updatedAt":"t","baseRepo":"paiml/aprender@0000000000000000000000000000000000000000"}"#;
787        let (_, s) = run(&[
788            (
789                "pull-request",
790                "evidence/github/pull-request/paiml__aprender__1.json",
791                pr,
792            ),
793            ("repo", "evidence/github/repo/paiml__aprender.json", REPO),
794        ]);
795        assert_eq!(
796            s.unresolved, 1,
797            "a different commit is a different snapshot"
798        );
799    }
800
801    #[test]
802    fn a_milestone_updated_at_mismatch_and_a_missing_version_field_are_refused() {
803        let bad = MILESTONE.replacen(
804            r#""updatedAt":"2026-09-24T07:03:22Z""#,
805            r#""updatedAt":"2026-01-01T00:00:00Z""#,
806            1,
807        );
808        let (_, s) = run(&[(
809            "milestone",
810            "evidence/github/milestone/paiml__aprender__3.json",
811            &bad,
812        )]);
813        assert!(only_error(&s).contains("disagrees with the snapshot's own `updatedAt`"));
814        let none = r#"{"ref":"paiml/aprender#3@x","repo":"paiml/aprender","number":3}"#;
815        let (_, s) = run(&[(
816            "milestone",
817            "evidence/github/milestone/paiml__aprender__3.json",
818            none,
819        )]);
820        assert!(only_error(&s).contains("no `updatedAt` field"));
821    }
822
823    #[test]
824    fn a_malformed_ref_is_refused_naming_the_grammar() {
825        for (ty, r) in [
826            (
827                "repo",
828                "paiml/aprender#3@aa7c6ef03ee7b7f8d8dc09dc393e97619952d95f",
829            ),
830            ("repo", "paiml/aprender@main"),
831            ("issue", "paiml/aprender@t"),
832            ("issue", "paiml/aprender#x@t"),
833            ("issue", "aprender#3@t"),
834            ("issue", "paiml/aprender#3@"),
835        ] {
836            let doc = format!(r#"{{"ref":"{r}","updatedAt":"t","sha":"t"}}"#);
837            let (_, s) = run(&[(ty, "evidence/github/x/y.json", &doc)]);
838            assert!(only_error(&s).contains("`ref`"), "{ty} {r}: {:?}", s.errors);
839        }
840        let (_, s) = run(&[(
841            "issue",
842            "evidence/github/issue/a.json",
843            r#"{"updatedAt":"t"}"#,
844        )]);
845        assert!(only_error(&s).contains("no string `ref`"));
846    }
847
848    #[test]
849    fn an_identity_the_snapshot_contradicts_or_a_wrong_file_name_is_refused() {
850        let wrong_repo =
851            MILESTONE.replacen(r#""repo":"paiml/aprender""#, r#""repo":"paiml/infra""#, 1);
852        let (_, s) = run(&[(
853            "milestone",
854            "evidence/github/milestone/paiml__aprender__3.json",
855            &wrong_repo,
856        )]);
857        assert!(only_error(&s).contains("`repo` is `paiml/infra`"));
858        let wrong_n = MILESTONE.replacen(r#""number":3"#, r#""number":4"#, 1);
859        let (_, s) = run(&[(
860            "milestone",
861            "evidence/github/milestone/paiml__aprender__3.json",
862            &wrong_n,
863        )]);
864        assert!(only_error(&s).contains("`number` is `4`"));
865        let (_, s) = run(&[("milestone", "evidence/github/milestone/m3.json", MILESTONE)]);
866        assert!(only_error(&s).contains("slug `paiml__aprender__3.json`"));
867    }
868
869    #[test]
870    fn a_nested_object_and_a_second_snapshot_of_one_identity_are_refused() {
871        let nested = MILESTONE.replacen(r#""state":"CLOSED""#, r#""creator":{"login":"x"}"#, 1);
872        let (g, s) = run(&[(
873            "milestone",
874            "evidence/github/milestone/paiml__aprender__3.json",
875            &nested,
876        )]);
877        assert!(only_error(&s).contains("`creator`"), "{:?}", s.errors);
878        assert!(g.is_empty());
879        let (_, s) = run(&[
880            (
881                "milestone",
882                "evidence/github/milestone/paiml__aprender__3.json",
883                MILESTONE,
884            ),
885            (
886                "milestone",
887                "evidence/github/milestone/paiml__aprender__3.json",
888                MILESTONE,
889            ),
890        ]);
891        assert!(only_error(&s).contains("a second snapshot"));
892        assert_eq!(s.by_type["milestone"], 1);
893    }
894
895    #[test]
896    fn a_conforming_snapshot_is_one_typed_root_with_prefixed_literals() {
897        let (g, s) = run(&[("repo", "evidence/github/repo/paiml__aprender.json", REPO)]);
898        assert!(s.errors.is_empty());
899        assert_eq!(
900            s.by_type,
901            [
902                ("issue", 0),
903                ("milestone", 0),
904                ("pull-request", 0),
905                ("repo", 1)
906            ]
907            .into_iter()
908            .map(|(k, v)| (k.to_string(), v))
909            .collect::<BTreeMap<_, _>>(),
910            "every declared type is a key, the empty ones 0"
911        );
912        let subj = iri("repo", &format!("paiml/aprender@{SHA}"));
913        assert!(g
914            .objects(&subj, RDF_TYPE)
915            .contains(&&Term::iri(expand("ont:Repo"))));
916        assert_eq!(
917            g.objects(&subj, &expand("repo:sha")),
918            vec![&Term::string(SHA)]
919        );
920    }
921
922    #[test]
923    fn the_walk_refuses_a_directory_sigma_does_not_declare_and_a_non_json_file() {
924        let d = std::env::temp_dir().join(format!("pv-github-walk-{}", std::process::id()));
925        let _ = std::fs::remove_dir_all(&d);
926        let base = d.join(EVIDENCE_DIR);
927        std::fs::create_dir_all(base.join("repo")).unwrap();
928        std::fs::create_dir_all(base.join("discussion")).unwrap();
929        std::fs::write(base.join("repo/paiml__aprender.json"), REPO).unwrap();
930        std::fs::write(base.join("repo/notes.txt"), "x").unwrap();
931        let mut g = Graph::new();
932        let s = extract(&d, &types(), &mut g);
933        let files: Vec<&str> = s.errors.iter().map(|e| e.file.as_str()).collect();
934        assert_eq!(
935            files,
936            vec![
937                "evidence/github/discussion",
938                "evidence/github/repo/notes.txt"
939            ],
940            "{:?}",
941            s.errors
942        );
943        assert_eq!(s.by_type["repo"], 1);
944        let mut g2 = Graph::new();
945        assert_eq!(extract(&d, &types(), &mut g2), s, "deterministic");
946        assert_eq!(g.to_ntriples(), g2.to_ntriples());
947        let _ = std::fs::remove_dir_all(&d);
948        let none = extract(&d, &types(), &mut Graph::new());
949        assert!(none.errors.is_empty() && none.by_type.values().all(|n| *n == 0));
950    }
951
952    #[test]
953    fn the_control_types_are_what_sigma_declares() {
954        let sigma_path =
955            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts/ontology.yaml");
956        let sigma = Sigma::from_yaml(&std::fs::read_to_string(sigma_path).unwrap()).unwrap();
957        assert_eq!(snapshot_types(&sigma), control_types());
958    }
959
960    #[test]
961    fn the_resolves_table_is_what_the_contract_declares() {
962        let path =
963            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts/github-entities-v1.yaml");
964        let doc: serde_yaml::Value =
965            serde_yaml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
966        let by_class: BTreeMap<String, SnapshotType> = control_types()
967            .into_iter()
968            .map(|t| (t.root_class.clone(), t))
969            .collect();
970        let mut declared = BTreeSet::new();
971        for shape in doc["shapes"].as_sequence().unwrap() {
972            let t = &by_class[shape["targetClass"].as_str().unwrap()];
973            for p in shape["properties"].as_sequence().unwrap() {
974                if let Some(target) = p.get("resolves").and_then(|r| r.as_str()) {
975                    let path = p["path"].as_str().unwrap();
976                    let key = path.strip_prefix(&format!("{}:", t.prefix)).unwrap();
977                    declared.insert((t.name.clone(), key.to_string(), target.to_string()));
978                }
979            }
980        }
981        let table: BTreeSet<_> = RESOLVES
982            .iter()
983            .map(|(a, b, c)| ((*a).to_string(), (*b).to_string(), (*c).to_string()))
984            .collect();
985        assert_eq!(declared, table);
986    }
987
988    #[test]
989    fn every_positive_control_fires_and_an_unknown_type_does_not() {
990        for t in control_types() {
991            assert!(positive_control(&t.name), "pc_extract.{}", t.name);
992        }
993        assert!(!positive_control("discussion"));
994    }
995
996    /// #4587: `snapshot_types` needs BOTH `implemented` and the `json` extractor, plus a vocabulary.
997    #[test]
998    fn snapshot_types_needs_implemented_and_the_json_extractor() {
999        let sigma_path =
1000            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contracts/ontology.yaml");
1001        let mut sigma = Sigma::from_yaml(&std::fs::read_to_string(sigma_path).expect("read sigma"))
1002            .expect("parse sigma");
1003        let vocab = sigma
1004            .entity_types
1005            .iter()
1006            .find_map(|e| e.vocabulary.clone())
1007            .expect("a vocabulary-carrying type");
1008        let before = snapshot_types(&sigma);
1009        for (name, extractor, implemented) in [
1010            ("k4-unimplemented", "json", false),
1011            ("k4-other-extractor", "yaml", true),
1012        ] {
1013            sigma
1014                .entity_types
1015                .push(crate::ontology::sigma::EntityTypeDecl {
1016                    name: name.to_string(),
1017                    extractor: extractor.to_string(),
1018                    implemented,
1019                    vocabulary: Some(vocab.clone()),
1020                });
1021        }
1022        assert_eq!(snapshot_types(&sigma), before);
1023    }
1024
1025    #[test]
1026    fn refusal_display_is_file_colon_what() {
1027        let r = Refusal {
1028            file: "evidence/github/repo/a.json".to_string(),
1029            what: "because".to_string(),
1030        };
1031        assert_eq!(r.to_string(), "evidence/github/repo/a.json: because");
1032    }
1033
1034    /// #4587: every name part and the version slot are checked one by one.
1035    #[test]
1036    fn parse_ref_checks_each_name_and_the_sha_shape() {
1037        let ts = types();
1038        let repo_t = &ts[0];
1039        let issue_t = &ts[1];
1040        for bad in [
1041            "o w/r#3@t",
1042            "/r#3@t",
1043            "o/#3@t",
1044            "o/r r#3@t",
1045            "o/r#3@a b",
1046            "o$/r#3@t",
1047            "o/r$#3@t",
1048        ] {
1049            assert!(parse_ref(issue_t, bad).is_err(), "{bad}");
1050        }
1051        assert!(parse_ref(issue_t, "o-w_n.1/r-p_o.2#3@t").is_ok());
1052        let sha = "0123456789abcdef0123456789abcdef01234567";
1053        assert!(parse_ref(repo_t, &format!("o/r@{sha}")).is_ok());
1054        // short hex, and 40 non-hex bytes, are not a commit sha
1055        assert!(parse_ref(repo_t, "o/r@abc123").is_err());
1056        assert!(parse_ref(repo_t, &format!("o/r@{}", "z".repeat(40))).is_err());
1057        assert!(parse_ref(repo_t, &format!("o/r@{}", "A".repeat(40))).is_err());
1058        assert!(parse_ref(repo_t, &format!("o w/r@{sha}")).is_err());
1059        assert!(parse_ref(repo_t, &format!("o/r w@{sha}")).is_err());
1060        assert!(parse_ref(repo_t, &format!("/r@{sha}")).is_err());
1061    }
1062
1063    /// #4587: a reference key must be a string on ITS OWN type, and only there.
1064    #[test]
1065    fn a_non_string_reference_key_is_refused_on_its_own_type_only() {
1066        let (_, s) = run(&[(
1067            "pull-request",
1068            "evidence/github/pull-request/o__r__1.json",
1069            r#"{"ref":"o/r#1@t","updatedAt":"t","baseRepo":5}"#,
1070        )]);
1071        assert!(
1072            only_error(&s).contains("`baseRepo` must name a snapshot by string"),
1073            "{:?}",
1074            s.errors
1075        );
1076        let (_, s) = run(&[(
1077            "issue",
1078            "evidence/github/issue/o__r__2.json",
1079            r#"{"ref":"o/r#2@t","updatedAt":"t","milestone":7}"#,
1080        )]);
1081        assert!(
1082            only_error(&s).contains("`milestone` must name a snapshot by string"),
1083            "{:?}",
1084            s.errors
1085        );
1086        // a number under another type's reference key is not this type's business
1087        let (_, s) = run(&[(
1088            "pull-request",
1089            "evidence/github/pull-request/o__r__3.json",
1090            r#"{"ref":"o/r#3@t","updatedAt":"t","milestone":7}"#,
1091        )]);
1092        assert!(s.errors.is_empty(), "{:?}", s.errors);
1093    }
1094
1095    /// #4587: only a regular file named `*.json` is read; the refusal names why.
1096    #[test]
1097    fn the_walk_reads_only_regular_json_files() {
1098        let d = std::env::temp_dir().join(format!("pv-github-walk-k5-{}", std::process::id()));
1099        let _ = std::fs::remove_dir_all(&d);
1100        let base = d.join(EVIDENCE_DIR).join("repo");
1101        std::fs::create_dir_all(base.join("dir.json")).expect("mkdir");
1102        std::fs::write(base.join("paiml__aprender.txt"), REPO).expect("write");
1103        let s = extract(&d, &types(), &mut Graph::new());
1104        let _ = std::fs::remove_dir_all(&d);
1105        assert_eq!(s.errors.len(), 2, "{:?}", s.errors);
1106        for e in &s.errors {
1107            assert_eq!(e.what, "not a `.json` snapshot", "{e}");
1108        }
1109        assert_eq!(s.by_type["repo"], 0);
1110    }
1111
1112    /// #4587: `insert_edge` resolves the target type by BOTH the type name and the key.
1113    #[test]
1114    fn insert_edge_matches_the_resolves_row_on_type_and_key() {
1115        let ts = types();
1116        let issue = &ts[1];
1117        let mut g = Graph::new();
1118        let mut stats = GithubStats::default();
1119        // `baseRepo` belongs to pull-request, not issue: no row, so it cannot resolve
1120        insert_edge(
1121            &mut g,
1122            &ts,
1123            issue,
1124            "s",
1125            "baseRepo",
1126            "o/r".to_string(),
1127            Some("o/r@x".to_string()),
1128            &mut stats,
1129        );
1130        assert_eq!(stats.unresolved, 1);
1131        assert!(g.objects("s", &expand("issue:baseRepo")).is_empty());
1132        assert_eq!(g.objects("s", &expand("issue:baseRepoUnresolved")).len(), 1);
1133    }
1134}