1use 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
38pub const EVIDENCE_DIR: &str = "evidence/github";
40
41pub const RESOLVES: [(&str, &str, &str); 2] = [
45 ("pull-request", "baseRepo", "repo"),
46 ("issue", "milestone", "milestone"),
47];
48
49pub const UNRESOLVED_SUFFIX: &str = "Unresolved";
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SnapshotType {
55 pub name: String,
56 pub prefix: String,
57 pub root_class: String,
58 pub version: String,
60}
61
62impl SnapshotType {
63 fn numbered(&self) -> bool {
66 self.name != "repo"
67 }
68}
69
70#[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
103pub struct GithubStats {
104 pub by_type: BTreeMap<String, usize>,
107 pub errors: Vec<Refusal>,
108 pub unresolved: usize,
110}
111
112#[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
188fn 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
209fn 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
228fn 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
262fn 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
291struct Accepted {
293 ty: usize,
294 file: String,
295 r: Ref,
296 map: serde_json::Map<String, serde_json::Value>,
297}
298
299pub 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
349fn 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
368fn 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
415fn 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
430fn 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#[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
516fn 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#[must_use]
576pub fn positive_control(entity_type: &str) -> bool {
577 match entity_type {
578 "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" => {
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" => {
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" => {
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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 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}