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