1use std::collections::{BTreeMap, BTreeSet, HashSet};
30
31use sem_core::model::change::ChangeType;
32use sem_core::model::entity::SemanticEntity;
33use sem_core::model::identity::match_entities;
34
35use crate::arity::{self, CallSite, Signature};
36use crate::parsers::entities_of;
37use crate::wire::{
38 Bindings, Finding, Findings, Reference, RelatedRef, Rename, SCHEMA_VERSION_1_1,
39 SCHEMA_VERSION_1_4,
40};
41use weave_core::binding::{has_call_reference, has_definition};
42
43pub type Tree = BTreeMap<String, String>;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub(crate) enum Side {
50 Ours,
51 Theirs,
52}
53
54impl Side {
55 fn wire(self) -> &'static str {
56 match self {
57 Side::Ours => "ours",
58 Side::Theirs => "theirs",
59 }
60 }
61 fn other(self) -> Side {
62 match self {
63 Side::Ours => Side::Theirs,
64 Side::Theirs => Side::Ours,
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
71struct Def {
72 name: String,
73 entity_type: String,
74 signature: Option<Signature>,
77}
78
79#[derive(Default)]
83struct FileVersions {
84 base: Option<String>,
85 ours: Option<String>,
86 theirs: Option<String>,
87 base_defs: Vec<Def>,
88 ours_defs: Vec<Def>,
89 theirs_defs: Vec<Def>,
90 ours_renames: BTreeMap<String, String>,
92 theirs_renames: BTreeMap<String, String>,
93}
94
95impl FileVersions {
96 fn version(&self, side: Side) -> Option<&String> {
97 match side {
98 Side::Ours => self.ours.as_ref(),
99 Side::Theirs => self.theirs.as_ref(),
100 }
101 }
102 fn defs(&self, side: Side) -> &[Def] {
103 match side {
104 Side::Ours => &self.ours_defs,
105 Side::Theirs => &self.theirs_defs,
106 }
107 }
108 fn renames(&self, side: Side) -> &BTreeMap<String, String> {
109 match side {
110 Side::Ours => &self.ours_renames,
111 Side::Theirs => &self.theirs_renames,
112 }
113 }
114 fn touched(&self, side: Side) -> bool {
116 self.version(side) != self.base.as_ref()
117 }
118}
119
120fn defs_of(entities: &[SemanticEntity], path: &str) -> Vec<Def> {
121 entities
122 .iter()
123 .map(|e| Def {
124 name: e.name.clone(),
125 entity_type: e.entity_type.clone(),
126 signature: (e.entity_type == "function")
127 .then(|| arity::params(path, &e.name, &e.content))
128 .flatten(),
129 })
130 .collect()
131}
132
133fn rename_map(
139 before: &[SemanticEntity],
140 after: &[SemanticEntity],
141 path: &str,
142) -> BTreeMap<String, String> {
143 let mut map = BTreeMap::new();
144 for c in match_entities(before, after, path, None, None, None).changes {
145 if c.change_type == ChangeType::Renamed {
146 if let Some(old) = c.old_entity_name.clone() {
147 if old != c.entity_name && !old.is_empty() && !c.entity_name.is_empty() {
148 map.insert(old, c.entity_name.clone());
149 }
150 }
151 }
152 }
153 map
154}
155
156pub(crate) use crate::parsers::is_supported;
157
158pub fn check(base: &Tree, ours: &Tree, theirs: &Tree) -> Vec<Findings> {
164 let mut paths: BTreeSet<&String> = BTreeSet::new();
166 paths.extend(base.keys());
167 paths.extend(ours.keys());
168 paths.extend(theirs.keys());
169
170 let mut files: BTreeMap<String, FileVersions> = BTreeMap::new();
171 for path in paths {
172 if !is_supported(path) {
173 continue;
174 }
175 let mut fv = FileVersions {
176 base: base.get(path).cloned(),
177 ours: ours.get(path).cloned(),
178 theirs: theirs.get(path).cloned(),
179 ..Default::default()
180 };
181 let base_e = entities_of(path, fv.base.as_deref().unwrap_or(""));
182 let ours_e = entities_of(path, fv.ours.as_deref().unwrap_or(""));
183 let theirs_e = entities_of(path, fv.theirs.as_deref().unwrap_or(""));
184 fv.ours_renames = rename_map(&base_e, &ours_e, path);
185 fv.theirs_renames = rename_map(&base_e, &theirs_e, path);
186 fv.base_defs = defs_of(&base_e, path);
187 fv.ours_defs = defs_of(&ours_e, path);
188 fv.theirs_defs = defs_of(&theirs_e, path);
189 files.insert(path.clone(), fv);
190 }
191
192 let mut merged_defs: HashSet<String> = HashSet::new();
199 let mut base_defs_all: HashSet<String> = HashSet::new();
200 for fv in files.values() {
201 for d in &fv.base_defs {
202 base_defs_all.insert(d.name.clone());
203 }
204 let (o, t) = (fv.touched(Side::Ours), fv.touched(Side::Theirs));
205 let survivors: Vec<&Def> = match (o, t) {
206 (true, true) => fv.ours_defs.iter().chain(fv.theirs_defs.iter()).collect(),
207 (true, false) => fv.ours_defs.iter().collect(),
208 (false, true) => fv.theirs_defs.iter().collect(),
209 (false, false) => fv.base_defs.iter().collect(),
210 };
211 for d in survivors {
212 merged_defs.insert(d.name.clone());
213 }
214 }
215
216 let side_defs = |side: Side| -> HashSet<String> {
219 files
220 .values()
221 .filter(|fv| fv.version(side).is_some())
222 .flat_map(|fv| fv.defs(side))
223 .map(|def| def.name.clone())
224 .collect()
225 };
226 let ours_defs_all = side_defs(Side::Ours);
227 let theirs_defs_all = side_defs(Side::Theirs);
228
229 let mut out: FindingSink = FindingSink::default();
230
231 for actor in [Side::Ours, Side::Theirs] {
234 let other = actor.other();
235 for (path, fv) in &files {
236 if !fv.touched(actor) || fv.base.is_none() {
237 continue;
238 }
239 let actor_names: HashSet<&str> =
240 fv.defs(actor).iter().map(|d| d.name.as_str()).collect();
241 let renames = fv.renames(actor);
242 for d in &fv.base_defs {
243 if actor_names.contains(d.name.as_str()) {
244 continue;
245 }
246 if merged_defs.contains(&d.name) {
249 continue;
250 }
251 let renamed_to = renames.get(&d.name).cloned();
252 for (other_path, other_fv) in &files {
253 if other_path == path || !other_fv.touched(other) {
254 continue;
255 }
256 let Some(content) = other_fv.version(other) else {
257 continue;
258 };
259 if has_definition(content, &d.name) {
262 continue;
263 }
264 if !has_call_reference(content, &d.name) {
265 continue;
266 }
267 out.push(
268 other_path,
269 dangling_finding(
270 &d.name,
271 &d.entity_type,
272 actor,
273 path,
274 renamed_to.as_deref(),
275 ),
276 renamed_to.as_ref().map(|to| Rename {
277 from: d.name.clone(),
278 to: to.clone(),
279 side: actor.wire().to_string(),
280 }),
281 Reference {
282 entity: d.name.clone(),
283 defined_in_output: false,
284 referenced_in_output: true,
285 },
286 );
287 }
288 }
289 }
290 }
291
292 for actor in [Side::Ours, Side::Theirs] {
296 let other = actor.other();
297 let other_defs_all = match other {
298 Side::Ours => &ours_defs_all,
299 Side::Theirs => &theirs_defs_all,
300 };
301 for (path, fv) in &files {
302 if !fv.touched(actor) {
303 continue;
304 }
305 let base_names: HashSet<&str> = fv.base_defs.iter().map(|d| d.name.as_str()).collect();
306 let rename_targets: HashSet<&str> =
307 fv.renames(actor).values().map(|s| s.as_str()).collect();
308 for d in fv.defs(actor) {
309 if base_names.contains(d.name.as_str()) || rename_targets.contains(d.name.as_str())
310 {
311 continue;
312 }
313 if base_defs_all.contains(&d.name) {
315 continue;
316 }
317 if other_defs_all.contains(&d.name) {
319 continue;
320 }
321 for (other_path, other_fv) in &files {
322 if other_path == path || !other_fv.touched(other) {
323 continue;
324 }
325 let Some(content) = other_fv.version(other) else {
326 continue;
327 };
328 if has_definition(content, &d.name) || !has_call_reference(content, &d.name) {
329 continue;
330 }
331 out.push(
332 other_path,
333 shadow_finding(&d.name, &d.entity_type, actor, path),
334 None,
335 Reference {
336 entity: d.name.clone(),
337 defined_in_output: false,
338 referenced_in_output: true,
339 },
340 );
341 }
342 }
343 }
344 }
345
346 let mut defined_in: BTreeMap<&str, usize> = BTreeMap::new();
364 for fv in files.values() {
365 let mut names: HashSet<&str> = HashSet::new();
366 for d in &fv.base_defs {
367 names.insert(d.name.as_str());
368 }
369 for n in names {
370 *defined_in.entry(n).or_default() += 1;
371 }
372 }
373 for actor in [Side::Ours, Side::Theirs] {
374 let other = actor.other();
375 for (path, fv) in &files {
376 if !fv.touched(actor) || fv.touched(other) || fv.base.is_none() {
377 continue;
378 }
379 for d in fv.defs(actor) {
380 let Some(new) = &d.signature else { continue };
381 if defined_in.get(d.name.as_str()) != Some(&1) {
382 continue;
383 }
384 let Some(old) = fv
385 .base_defs
386 .iter()
387 .find(|b| b.name == d.name)
388 .and_then(|b| b.signature.as_ref())
389 else {
390 continue;
391 };
392 if old.params == new.params {
393 continue;
394 }
395 for (other_path, other_fv) in &files {
396 if other_path == path
397 || !other_fv.touched(other)
398 || !arity::same_family(path, other_path)
399 {
400 continue;
401 }
402 let Some(content) = other_fv.version(other) else {
403 continue;
404 };
405 if has_definition(content, &d.name) {
406 continue;
407 }
408 let stale: Vec<CallSite> = arity::call_sites(other_path, content, &d.name)
409 .into_iter()
410 .filter(|c| c.args == Some(old.params))
411 .collect();
412 if stale.is_empty() {
413 continue;
414 }
415 out.push(
416 other_path,
417 arity_finding(&d.name, &d.entity_type, actor, path, old, new, &stale),
418 None,
419 Reference {
420 entity: d.name.clone(),
421 defined_in_output: false,
422 referenced_in_output: true,
423 },
424 );
425 }
426 }
427 }
428 }
429
430 out.into_documents()
431}
432
433fn arity_finding(
434 name: &str,
435 entity_type: &str,
436 actor: Side,
437 defining_file: &str,
438 old: &Signature,
439 new: &Signature,
440 stale: &[CallSite],
441) -> Finding {
442 let mut f = Finding::derived("ARITY", name, entity_type);
443 f.side = Some(actor.wire().to_string());
444 f.confidence = Some("high".to_string());
447 f.base = Some(old.text.clone());
450 match actor {
451 Side::Ours => f.ours = Some(new.text.clone()),
452 Side::Theirs => f.theirs = Some(new.text.clone()),
453 }
454 let sites = stale
455 .iter()
456 .map(|c| format!("line {}: `{}`", c.line, c.text))
457 .collect::<Vec<_>>()
458 .join("; ");
459 f.suggestion = Some(format!(
460 "{} changed `{name}` in `{defining_file}` from {} to {} parameter(s) (`{}` → `{}`), \
461 but this file still calls it with {}: {sites}. Update these calls to the new \
462 signature.",
463 actor.wire(),
464 old.params,
465 new.params,
466 old.text,
467 new.text,
468 old.params,
469 ));
470 f.related = vec![RelatedRef {
471 entity: name.to_string(),
472 entity_type: Some(entity_type.to_string()),
473 file: Some(defining_file.to_string()),
474 }];
475 f
476}
477
478fn dangling_finding(
479 name: &str,
480 entity_type: &str,
481 actor: Side,
482 defining_file: &str,
483 renamed_to: Option<&str>,
484) -> Finding {
485 let mut f = Finding::derived("DANGLING", name, entity_type);
486 f.side = Some(actor.wire().to_string());
487 f.confidence = Some("high".to_string());
488 f.suggestion = Some(match renamed_to {
489 Some(to) => format!(
490 "`{name}` is still called here but {} renamed it to `{to}` in `{defining_file}`, \
491 and nothing else in the repo defines it. Rewrite these call sites to `{to}`.",
492 actor.wire()
493 ),
494 None => format!(
495 "`{name}` is still called here but {} deleted it from `{defining_file}`, and nothing \
496 else in the repo defines it. Restore the definition or remove the call sites.",
497 actor.wire()
498 ),
499 });
500 f.related = vec![RelatedRef {
501 entity: renamed_to.unwrap_or(name).to_string(),
502 entity_type: Some(entity_type.to_string()),
503 file: Some(defining_file.to_string()),
504 }];
505 f
506}
507
508fn shadow_finding(name: &str, entity_type: &str, actor: Side, defining_file: &str) -> Finding {
509 let mut f = Finding::derived("SHADOW", name, entity_type);
510 f.side = Some(actor.wire().to_string());
511 f.confidence = Some("medium".to_string());
514 f.suggestion = Some(format!(
515 "`{name}` is called here and was resolving outside this merge (an import or a builtin), \
516 but {} added a new definition of `{name}` in `{defining_file}`. Check which one this \
517 call is meant to reach.",
518 actor.wire()
519 ));
520 f.related = vec![RelatedRef {
521 entity: name.to_string(),
522 entity_type: Some(entity_type.to_string()),
523 file: Some(defining_file.to_string()),
524 }];
525 f
526}
527
528#[derive(Default)]
531struct DocumentParts {
532 findings: Vec<Finding>,
533 renames: Vec<Rename>,
534 references: Vec<Reference>,
535}
536
537#[derive(Default)]
539struct FindingSink {
540 per_file: BTreeMap<String, DocumentParts>,
541 seen: HashSet<(String, String, String)>,
544}
545
546impl FindingSink {
547 fn push(&mut self, file: &str, finding: Finding, rename: Option<Rename>, reference: Reference) {
548 let key = (
549 file.to_string(),
550 finding.class.clone(),
551 finding.entity.clone(),
552 );
553 if !self.seen.insert(key) {
554 return;
555 }
556 let slot = self.per_file.entry(file.to_string()).or_default();
557 slot.findings.push(finding);
558 if let Some(r) = rename {
559 if !slot.renames.contains(&r) {
560 slot.renames.push(r);
561 }
562 }
563 if !slot.references.contains(&reference) {
564 slot.references.push(reference);
565 }
566 }
567
568 fn into_documents(self) -> Vec<Findings> {
569 self.per_file
570 .into_iter()
571 .map(|(file, parts)| {
572 let confidence = if parts
576 .findings
577 .iter()
578 .any(|f| f.class == "DANGLING" || f.class == "ARITY")
579 {
580 "low"
581 } else {
582 "medium"
583 };
584 let version = if parts.findings.iter().any(|f| f.class == "ARITY") {
587 SCHEMA_VERSION_1_4
588 } else {
589 SCHEMA_VERSION_1_1
590 };
591 Findings {
592 schema_version: version.to_string(),
593 file,
594 scope: Some("repo".to_string()),
595 clean: true,
596 confidence: confidence.to_string(),
597 findings: parts.findings,
598 ops: Vec::new(),
601 bindings: Bindings {
602 renames: parts.renames,
603 references: parts.references,
604 },
605 }
606 })
607 .collect()
608 }
609}
610
611pub fn read_dir_tree(root: &std::path::Path) -> std::io::Result<Tree> {
613 fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Tree) -> std::io::Result<()> {
614 for entry in std::fs::read_dir(dir)? {
615 let entry = entry?;
616 let path = entry.path();
617 let name = entry.file_name();
618 let name = name.to_string_lossy();
619 if name == ".git" || name == "target" || name == "node_modules" {
620 continue;
621 }
622 if path.is_dir() {
623 walk(root, &path, out)?;
624 } else if let Ok(rel) = path.strip_prefix(root) {
625 let rel = rel.to_string_lossy().replace('\\', "/");
626 if !is_supported(&rel) {
627 continue;
628 }
629 if let Ok(content) = std::fs::read_to_string(&path) {
630 out.insert(rel, content);
631 }
632 }
633 }
634 Ok(())
635 }
636 let mut out = Tree::new();
637 walk(root, root, &mut out)?;
638 Ok(out)
639}
640
641pub fn total_findings(docs: &[Findings]) -> usize {
643 docs.iter().map(|d| d.findings.len()).sum()
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 fn tree(pairs: &[(&str, &str)]) -> Tree {
651 pairs
652 .iter()
653 .map(|(p, c)| (p.to_string(), c.to_string()))
654 .collect()
655 }
656
657 #[test]
660 fn a_rename_in_one_file_dangles_a_caller_in_another() {
661 let base = tree(&[
662 ("a.py", "def fetch_user(uid):\n return uid\n"),
663 ("b.py", "def report():\n return 1\n"),
664 ]);
665 let ours = tree(&[
667 ("a.py", "def get_user(uid):\n return uid\n"),
668 ("b.py", "def report():\n return 1\n"),
669 ]);
670 let theirs = tree(&[
672 ("a.py", "def fetch_user(uid):\n return uid\n"),
673 (
674 "b.py",
675 "def report():\n return 1\n\n\ndef summary():\n return fetch_user(1)\n",
676 ),
677 ]);
678
679 let docs = check(&base, &ours, &theirs);
680 assert_eq!(docs.len(), 1, "the caller's file is the one at risk");
681 assert_eq!(docs[0].file, "b.py");
682 let f = &docs[0].findings[0];
683 assert_eq!(f.class, "DANGLING");
684 assert_eq!(f.entity, "fetch_user");
685 assert_eq!(f.side.as_deref(), Some("ours"));
686 assert_eq!(f.related[0].file.as_deref(), Some("a.py"));
687 assert_eq!(docs[0].bindings.renames[0].to, "get_user");
688 }
689
690 #[test]
691 fn a_delete_in_one_file_dangles_a_caller_in_another() {
692 let base = tree(&[
693 ("a.py", "def fetch_user(uid):\n return uid\n"),
694 ("b.py", "def report():\n return 1\n"),
695 ]);
696 let ours = tree(&[("a.py", "\n"), ("b.py", "def report():\n return 1\n")]);
697 let theirs = tree(&[
698 ("a.py", "def fetch_user(uid):\n return uid\n"),
699 ("b.py", "def report():\n return fetch_user(1)\n"),
700 ]);
701 let docs = check(&base, &ours, &theirs);
702 let f = &docs[0].findings[0];
703 assert_eq!(f.class, "DANGLING");
704 assert!(f.suggestion.as_ref().unwrap().contains("deleted"));
705 }
706
707 #[test]
708 fn a_surviving_definition_elsewhere_is_not_dangling() {
709 let base = tree(&[
711 ("a.py", "def fetch_user(uid):\n return uid\n"),
712 ("c.py", "def fetch_user(uid):\n return uid\n"),
713 ("b.py", "def report():\n return 1\n"),
714 ]);
715 let ours = tree(&[
716 ("a.py", "def get_user(uid):\n return uid\n"),
717 ("c.py", "def fetch_user(uid):\n return uid\n"),
718 ("b.py", "def report():\n return 1\n"),
719 ]);
720 let theirs = tree(&[
721 ("a.py", "def fetch_user(uid):\n return uid\n"),
722 ("c.py", "def fetch_user(uid):\n return uid\n"),
723 ("b.py", "def report():\n return fetch_user(1)\n"),
724 ]);
725 assert!(check(&base, &ours, &theirs).is_empty());
726 }
727
728 #[test]
729 fn an_untouched_caller_is_not_reported() {
730 let base = tree(&[
733 ("a.py", "def fetch_user(uid):\n return uid\n"),
734 ("b.py", "def report():\n return fetch_user(1)\n"),
735 ]);
736 let ours = tree(&[
737 ("a.py", "def get_user(uid):\n return uid\n"),
738 ("b.py", "def report():\n return fetch_user(1)\n"),
739 ]);
740 let theirs = base.clone();
741 assert!(check(&base, &ours, &theirs).is_empty());
742 }
743
744 #[test]
745 fn attribute_access_and_bare_mentions_do_not_count_as_calls() {
746 let base = tree(&[
747 ("a.py", "def fetch_user(uid):\n return uid\n"),
748 ("b.py", "def report():\n return 1\n"),
749 ]);
750 let ours = tree(&[
751 ("a.py", "def get_user(uid):\n return uid\n"),
752 ("b.py", "def report():\n return 1\n"),
753 ]);
754 let theirs = tree(&[
755 ("a.py", "def fetch_user(uid):\n return uid\n"),
756 (
757 "b.py",
758 "def report():\n log('fetch_user')\n return db.fetch_user(1)\n",
759 ),
760 ]);
761 assert!(check(&base, &ours, &theirs).is_empty());
762 }
763
764 #[test]
765 fn a_new_definition_capturing_another_sides_call_is_shadow() {
766 let base = tree(&[
767 ("a.py", "def existing():\n return 0\n"),
768 ("b.py", "def report():\n return 1\n"),
769 ]);
770 let ours = tree(&[
772 (
773 "a.py",
774 "def existing():\n return 0\n\n\ndef render(x):\n return x\n",
775 ),
776 ("b.py", "def report():\n return 1\n"),
777 ]);
778 let theirs = tree(&[
780 ("a.py", "def existing():\n return 0\n"),
781 ("b.py", "def report():\n return render(1)\n"),
782 ]);
783 let docs = check(&base, &ours, &theirs);
784 assert_eq!(docs.len(), 1);
785 assert_eq!(docs[0].findings[0].class, "SHADOW");
786 assert_eq!(docs[0].findings[0].entity, "render");
787 }
788
789 #[test]
792 fn a_parameter_added_in_one_file_flags_an_old_arity_call_in_another() {
793 let base = tree(&[
794 ("a.py", "def fetch_user(uid):\n return uid\n"),
795 ("b.py", "def report():\n return 1\n"),
796 ]);
797 let ours = tree(&[
798 ("a.py", "def fetch_user(uid, tenant):\n return uid\n"),
799 ("b.py", "def report():\n return 1\n"),
800 ]);
801 let theirs = tree(&[
802 ("a.py", "def fetch_user(uid):\n return uid\n"),
803 (
804 "b.py",
805 "def report():\n return fetch_user(1)\n\n\ndef other():\n return fetch_user(1, 2)\n",
806 ),
807 ]);
808 let docs = check(&base, &ours, &theirs);
809 assert_eq!(docs.len(), 1);
810 assert_eq!(docs[0].file, "b.py");
811 assert_eq!(docs[0].schema_version, "1.4.0");
812 let f = &docs[0].findings[0];
813 assert_eq!(f.class, "ARITY");
814 assert_eq!(f.side.as_deref(), Some("ours"));
815 assert_eq!(f.base.as_deref(), Some("def fetch_user(uid)"));
816 assert_eq!(f.ours.as_deref(), Some("def fetch_user(uid, tenant)"));
817 let s = f.suggestion.as_ref().unwrap();
818 assert!(s.contains("line 2: `fetch_user(1)`"), "{s}");
819 assert!(
820 !s.contains("fetch_user(1, 2)"),
821 "a call already at the new arity is fine"
822 );
823 }
824
825 #[test]
827 fn a_defaulted_new_parameter_is_not_an_arity_break() {
828 let base = tree(&[
829 ("a.py", "def fetch_user(uid):\n return uid\n"),
830 ("b.py", "def report():\n return 1\n"),
831 ]);
832 let ours = tree(&[
833 (
834 "a.py",
835 "def fetch_user(uid, tenant=None):\n return uid\n",
836 ),
837 ("b.py", "def report():\n return 1\n"),
838 ]);
839 let theirs = tree(&[
840 ("a.py", "def fetch_user(uid):\n return uid\n"),
841 ("b.py", "def report():\n return fetch_user(1)\n"),
842 ]);
843 assert!(check(&base, &ours, &theirs).is_empty());
844 }
845
846 #[test]
847 fn unsupported_files_are_skipped_rather_than_guessed_at() {
848 let base = tree(&[("a.bin", "def fetch_user(uid): pass\n")]);
849 let ours = tree(&[("a.bin", "def get_user(uid): pass\n")]);
850 let theirs = tree(&[("a.bin", "def fetch_user(uid): pass\nfetch_user(1)\n")]);
851 assert!(check(&base, &ours, &theirs).is_empty());
852 }
853
854 #[test]
855 fn documents_conform_to_the_schemas_required_keys() {
856 let base = tree(&[
857 ("a.py", "def fetch_user(uid):\n return uid\n"),
858 ("b.py", "def report():\n return 1\n"),
859 ]);
860 let ours = tree(&[
861 ("a.py", "def get_user(uid):\n return uid\n"),
862 ("b.py", "def report():\n return 1\n"),
863 ]);
864 let theirs = tree(&[
865 ("a.py", "def fetch_user(uid):\n return uid\n"),
866 ("b.py", "def report():\n return fetch_user(1)\n"),
867 ]);
868 let docs = check(&base, &ours, &theirs);
869 let v = serde_json::to_value(&docs[0]).unwrap();
870 for key in [
871 "schema_version",
872 "file",
873 "clean",
874 "confidence",
875 "findings",
876 "ops",
877 "bindings",
878 ] {
879 assert!(v.get(key).is_some(), "missing required key {key}");
880 }
881 assert_eq!(v["scope"], "repo");
882 assert_eq!(v["schema_version"], "1.1.0");
883 }
884}