1use std::collections::{BTreeMap, BTreeSet, HashSet};
28
29use sem_core::model::change::ChangeType;
30use sem_core::model::entity::SemanticEntity;
31use sem_core::model::identity::match_entities;
32
33use crate::parsers::entities_of;
34use crate::wire::{Bindings, Finding, Findings, Reference, RelatedRef, Rename, SCHEMA_VERSION_1_1};
35use weave_core::binding::{has_call_reference, has_definition};
36
37pub type Tree = BTreeMap<String, String>;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub(crate) enum Side {
44 Ours,
45 Theirs,
46}
47
48impl Side {
49 fn wire(self) -> &'static str {
50 match self {
51 Side::Ours => "ours",
52 Side::Theirs => "theirs",
53 }
54 }
55 fn other(self) -> Side {
56 match self {
57 Side::Ours => Side::Theirs,
58 Side::Theirs => Side::Ours,
59 }
60 }
61}
62
63#[derive(Debug, Clone)]
65struct Def {
66 name: String,
67 entity_type: String,
68}
69
70#[derive(Default)]
74struct FileVersions {
75 base: Option<String>,
76 ours: Option<String>,
77 theirs: Option<String>,
78 base_defs: Vec<Def>,
79 ours_defs: Vec<Def>,
80 theirs_defs: Vec<Def>,
81 ours_renames: BTreeMap<String, String>,
83 theirs_renames: BTreeMap<String, String>,
84}
85
86impl FileVersions {
87 fn version(&self, side: Side) -> Option<&String> {
88 match side {
89 Side::Ours => self.ours.as_ref(),
90 Side::Theirs => self.theirs.as_ref(),
91 }
92 }
93 fn defs(&self, side: Side) -> &[Def] {
94 match side {
95 Side::Ours => &self.ours_defs,
96 Side::Theirs => &self.theirs_defs,
97 }
98 }
99 fn renames(&self, side: Side) -> &BTreeMap<String, String> {
100 match side {
101 Side::Ours => &self.ours_renames,
102 Side::Theirs => &self.theirs_renames,
103 }
104 }
105 fn touched(&self, side: Side) -> bool {
107 self.version(side) != self.base.as_ref()
108 }
109}
110
111fn defs_of(entities: &[SemanticEntity]) -> Vec<Def> {
112 entities
113 .iter()
114 .map(|e| Def {
115 name: e.name.clone(),
116 entity_type: e.entity_type.clone(),
117 })
118 .collect()
119}
120
121fn rename_map(
127 before: &[SemanticEntity],
128 after: &[SemanticEntity],
129 path: &str,
130) -> BTreeMap<String, String> {
131 let mut map = BTreeMap::new();
132 for c in match_entities(before, after, path, None, None, None).changes {
133 if c.change_type == ChangeType::Renamed {
134 if let Some(old) = c.old_entity_name.clone() {
135 if old != c.entity_name && !old.is_empty() && !c.entity_name.is_empty() {
136 map.insert(old, c.entity_name.clone());
137 }
138 }
139 }
140 }
141 map
142}
143
144pub(crate) use crate::parsers::is_supported;
145
146pub fn check(base: &Tree, ours: &Tree, theirs: &Tree) -> Vec<Findings> {
152 let mut paths: BTreeSet<&String> = BTreeSet::new();
154 paths.extend(base.keys());
155 paths.extend(ours.keys());
156 paths.extend(theirs.keys());
157
158 let mut files: BTreeMap<String, FileVersions> = BTreeMap::new();
159 for path in paths {
160 if !is_supported(path) {
161 continue;
162 }
163 let mut fv = FileVersions {
164 base: base.get(path).cloned(),
165 ours: ours.get(path).cloned(),
166 theirs: theirs.get(path).cloned(),
167 ..Default::default()
168 };
169 let base_e = entities_of(path, fv.base.as_deref().unwrap_or(""));
170 let ours_e = entities_of(path, fv.ours.as_deref().unwrap_or(""));
171 let theirs_e = entities_of(path, fv.theirs.as_deref().unwrap_or(""));
172 fv.ours_renames = rename_map(&base_e, &ours_e, path);
173 fv.theirs_renames = rename_map(&base_e, &theirs_e, path);
174 fv.base_defs = defs_of(&base_e);
175 fv.ours_defs = defs_of(&ours_e);
176 fv.theirs_defs = defs_of(&theirs_e);
177 files.insert(path.clone(), fv);
178 }
179
180 let mut merged_defs: HashSet<String> = HashSet::new();
187 let mut base_defs_all: HashSet<String> = HashSet::new();
188 for fv in files.values() {
189 for d in &fv.base_defs {
190 base_defs_all.insert(d.name.clone());
191 }
192 let (o, t) = (fv.touched(Side::Ours), fv.touched(Side::Theirs));
193 let survivors: Vec<&Def> = match (o, t) {
194 (true, true) => fv.ours_defs.iter().chain(fv.theirs_defs.iter()).collect(),
195 (true, false) => fv.ours_defs.iter().collect(),
196 (false, true) => fv.theirs_defs.iter().collect(),
197 (false, false) => fv.base_defs.iter().collect(),
198 };
199 for d in survivors {
200 merged_defs.insert(d.name.clone());
201 }
202 }
203
204 let side_defs = |side: Side| -> HashSet<String> {
207 files
208 .values()
209 .filter(|fv| fv.version(side).is_some())
210 .flat_map(|fv| fv.defs(side))
211 .map(|def| def.name.clone())
212 .collect()
213 };
214 let ours_defs_all = side_defs(Side::Ours);
215 let theirs_defs_all = side_defs(Side::Theirs);
216
217 let mut out: FindingSink = FindingSink::default();
218
219 for actor in [Side::Ours, Side::Theirs] {
222 let other = actor.other();
223 for (path, fv) in &files {
224 if !fv.touched(actor) || fv.base.is_none() {
225 continue;
226 }
227 let actor_names: HashSet<&str> =
228 fv.defs(actor).iter().map(|d| d.name.as_str()).collect();
229 let renames = fv.renames(actor);
230 for d in &fv.base_defs {
231 if actor_names.contains(d.name.as_str()) {
232 continue;
233 }
234 if merged_defs.contains(&d.name) {
237 continue;
238 }
239 let renamed_to = renames.get(&d.name).cloned();
240 for (other_path, other_fv) in &files {
241 if other_path == path || !other_fv.touched(other) {
242 continue;
243 }
244 let Some(content) = other_fv.version(other) else {
245 continue;
246 };
247 if has_definition(content, &d.name) {
250 continue;
251 }
252 if !has_call_reference(content, &d.name) {
253 continue;
254 }
255 out.push(
256 other_path,
257 dangling_finding(
258 &d.name,
259 &d.entity_type,
260 actor,
261 path,
262 renamed_to.as_deref(),
263 ),
264 renamed_to.as_ref().map(|to| Rename {
265 from: d.name.clone(),
266 to: to.clone(),
267 side: actor.wire().to_string(),
268 }),
269 Reference {
270 entity: d.name.clone(),
271 defined_in_output: false,
272 referenced_in_output: true,
273 },
274 );
275 }
276 }
277 }
278 }
279
280 for actor in [Side::Ours, Side::Theirs] {
284 let other = actor.other();
285 let other_defs_all = match other {
286 Side::Ours => &ours_defs_all,
287 Side::Theirs => &theirs_defs_all,
288 };
289 for (path, fv) in &files {
290 if !fv.touched(actor) {
291 continue;
292 }
293 let base_names: HashSet<&str> = fv.base_defs.iter().map(|d| d.name.as_str()).collect();
294 let rename_targets: HashSet<&str> =
295 fv.renames(actor).values().map(|s| s.as_str()).collect();
296 for d in fv.defs(actor) {
297 if base_names.contains(d.name.as_str()) || rename_targets.contains(d.name.as_str())
298 {
299 continue;
300 }
301 if base_defs_all.contains(&d.name) {
303 continue;
304 }
305 if other_defs_all.contains(&d.name) {
307 continue;
308 }
309 for (other_path, other_fv) in &files {
310 if other_path == path || !other_fv.touched(other) {
311 continue;
312 }
313 let Some(content) = other_fv.version(other) else {
314 continue;
315 };
316 if has_definition(content, &d.name) || !has_call_reference(content, &d.name) {
317 continue;
318 }
319 out.push(
320 other_path,
321 shadow_finding(&d.name, &d.entity_type, actor, path),
322 None,
323 Reference {
324 entity: d.name.clone(),
325 defined_in_output: false,
326 referenced_in_output: true,
327 },
328 );
329 }
330 }
331 }
332 }
333
334 out.into_documents()
335}
336
337fn dangling_finding(
338 name: &str,
339 entity_type: &str,
340 actor: Side,
341 defining_file: &str,
342 renamed_to: Option<&str>,
343) -> Finding {
344 let mut f = Finding::derived("DANGLING", name, entity_type);
345 f.side = Some(actor.wire().to_string());
346 f.confidence = Some("high".to_string());
347 f.suggestion = Some(match renamed_to {
348 Some(to) => format!(
349 "`{name}` is still called here but {} renamed it to `{to}` in `{defining_file}`, \
350 and nothing else in the repo defines it. Rewrite these call sites to `{to}`.",
351 actor.wire()
352 ),
353 None => format!(
354 "`{name}` is still called here but {} deleted it from `{defining_file}`, and nothing \
355 else in the repo defines it. Restore the definition or remove the call sites.",
356 actor.wire()
357 ),
358 });
359 f.related = vec![RelatedRef {
360 entity: renamed_to.unwrap_or(name).to_string(),
361 entity_type: Some(entity_type.to_string()),
362 file: Some(defining_file.to_string()),
363 }];
364 f
365}
366
367fn shadow_finding(name: &str, entity_type: &str, actor: Side, defining_file: &str) -> Finding {
368 let mut f = Finding::derived("SHADOW", name, entity_type);
369 f.side = Some(actor.wire().to_string());
370 f.confidence = Some("medium".to_string());
373 f.suggestion = Some(format!(
374 "`{name}` is called here and was resolving outside this merge (an import or a builtin), \
375 but {} added a new definition of `{name}` in `{defining_file}`. Check which one this \
376 call is meant to reach.",
377 actor.wire()
378 ));
379 f.related = vec![RelatedRef {
380 entity: name.to_string(),
381 entity_type: Some(entity_type.to_string()),
382 file: Some(defining_file.to_string()),
383 }];
384 f
385}
386
387#[derive(Default)]
390struct DocumentParts {
391 findings: Vec<Finding>,
392 renames: Vec<Rename>,
393 references: Vec<Reference>,
394}
395
396#[derive(Default)]
398struct FindingSink {
399 per_file: BTreeMap<String, DocumentParts>,
400 seen: HashSet<(String, String, String)>,
403}
404
405impl FindingSink {
406 fn push(&mut self, file: &str, finding: Finding, rename: Option<Rename>, reference: Reference) {
407 let key = (
408 file.to_string(),
409 finding.class.clone(),
410 finding.entity.clone(),
411 );
412 if !self.seen.insert(key) {
413 return;
414 }
415 let slot = self.per_file.entry(file.to_string()).or_default();
416 slot.findings.push(finding);
417 if let Some(r) = rename {
418 if !slot.renames.contains(&r) {
419 slot.renames.push(r);
420 }
421 }
422 if !slot.references.contains(&reference) {
423 slot.references.push(reference);
424 }
425 }
426
427 fn into_documents(self) -> Vec<Findings> {
428 self.per_file
429 .into_iter()
430 .map(|(file, parts)| {
431 let confidence = if parts.findings.iter().any(|f| f.class == "DANGLING") {
435 "low"
436 } else {
437 "medium"
438 };
439 Findings {
440 schema_version: SCHEMA_VERSION_1_1.to_string(),
441 file,
442 scope: Some("repo".to_string()),
443 clean: true,
444 confidence: confidence.to_string(),
445 findings: parts.findings,
446 ops: Vec::new(),
449 bindings: Bindings {
450 renames: parts.renames,
451 references: parts.references,
452 },
453 }
454 })
455 .collect()
456 }
457}
458
459pub fn read_dir_tree(root: &std::path::Path) -> std::io::Result<Tree> {
461 fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Tree) -> std::io::Result<()> {
462 for entry in std::fs::read_dir(dir)? {
463 let entry = entry?;
464 let path = entry.path();
465 let name = entry.file_name();
466 let name = name.to_string_lossy();
467 if name == ".git" || name == "target" || name == "node_modules" {
468 continue;
469 }
470 if path.is_dir() {
471 walk(root, &path, out)?;
472 } else if let Ok(rel) = path.strip_prefix(root) {
473 let rel = rel.to_string_lossy().replace('\\', "/");
474 if !is_supported(&rel) {
475 continue;
476 }
477 if let Ok(content) = std::fs::read_to_string(&path) {
478 out.insert(rel, content);
479 }
480 }
481 }
482 Ok(())
483 }
484 let mut out = Tree::new();
485 walk(root, root, &mut out)?;
486 Ok(out)
487}
488
489pub fn total_findings(docs: &[Findings]) -> usize {
491 docs.iter().map(|d| d.findings.len()).sum()
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 fn tree(pairs: &[(&str, &str)]) -> Tree {
499 pairs
500 .iter()
501 .map(|(p, c)| (p.to_string(), c.to_string()))
502 .collect()
503 }
504
505 #[test]
508 fn a_rename_in_one_file_dangles_a_caller_in_another() {
509 let base = tree(&[
510 ("a.py", "def fetch_user(uid):\n return uid\n"),
511 ("b.py", "def report():\n return 1\n"),
512 ]);
513 let ours = tree(&[
515 ("a.py", "def get_user(uid):\n return uid\n"),
516 ("b.py", "def report():\n return 1\n"),
517 ]);
518 let theirs = tree(&[
520 ("a.py", "def fetch_user(uid):\n return uid\n"),
521 (
522 "b.py",
523 "def report():\n return 1\n\n\ndef summary():\n return fetch_user(1)\n",
524 ),
525 ]);
526
527 let docs = check(&base, &ours, &theirs);
528 assert_eq!(docs.len(), 1, "the caller's file is the one at risk");
529 assert_eq!(docs[0].file, "b.py");
530 let f = &docs[0].findings[0];
531 assert_eq!(f.class, "DANGLING");
532 assert_eq!(f.entity, "fetch_user");
533 assert_eq!(f.side.as_deref(), Some("ours"));
534 assert_eq!(f.related[0].file.as_deref(), Some("a.py"));
535 assert_eq!(docs[0].bindings.renames[0].to, "get_user");
536 }
537
538 #[test]
539 fn a_delete_in_one_file_dangles_a_caller_in_another() {
540 let base = tree(&[
541 ("a.py", "def fetch_user(uid):\n return uid\n"),
542 ("b.py", "def report():\n return 1\n"),
543 ]);
544 let ours = tree(&[("a.py", "\n"), ("b.py", "def report():\n return 1\n")]);
545 let theirs = tree(&[
546 ("a.py", "def fetch_user(uid):\n return uid\n"),
547 ("b.py", "def report():\n return fetch_user(1)\n"),
548 ]);
549 let docs = check(&base, &ours, &theirs);
550 let f = &docs[0].findings[0];
551 assert_eq!(f.class, "DANGLING");
552 assert!(f.suggestion.as_ref().unwrap().contains("deleted"));
553 }
554
555 #[test]
556 fn a_surviving_definition_elsewhere_is_not_dangling() {
557 let base = tree(&[
559 ("a.py", "def fetch_user(uid):\n return uid\n"),
560 ("c.py", "def fetch_user(uid):\n return uid\n"),
561 ("b.py", "def report():\n return 1\n"),
562 ]);
563 let ours = tree(&[
564 ("a.py", "def get_user(uid):\n return uid\n"),
565 ("c.py", "def fetch_user(uid):\n return uid\n"),
566 ("b.py", "def report():\n return 1\n"),
567 ]);
568 let theirs = tree(&[
569 ("a.py", "def fetch_user(uid):\n return uid\n"),
570 ("c.py", "def fetch_user(uid):\n return uid\n"),
571 ("b.py", "def report():\n return fetch_user(1)\n"),
572 ]);
573 assert!(check(&base, &ours, &theirs).is_empty());
574 }
575
576 #[test]
577 fn an_untouched_caller_is_not_reported() {
578 let base = tree(&[
581 ("a.py", "def fetch_user(uid):\n return uid\n"),
582 ("b.py", "def report():\n return fetch_user(1)\n"),
583 ]);
584 let ours = tree(&[
585 ("a.py", "def get_user(uid):\n return uid\n"),
586 ("b.py", "def report():\n return fetch_user(1)\n"),
587 ]);
588 let theirs = base.clone();
589 assert!(check(&base, &ours, &theirs).is_empty());
590 }
591
592 #[test]
593 fn attribute_access_and_bare_mentions_do_not_count_as_calls() {
594 let base = tree(&[
595 ("a.py", "def fetch_user(uid):\n return uid\n"),
596 ("b.py", "def report():\n return 1\n"),
597 ]);
598 let ours = tree(&[
599 ("a.py", "def get_user(uid):\n return uid\n"),
600 ("b.py", "def report():\n return 1\n"),
601 ]);
602 let theirs = tree(&[
603 ("a.py", "def fetch_user(uid):\n return uid\n"),
604 (
605 "b.py",
606 "def report():\n log('fetch_user')\n return db.fetch_user(1)\n",
607 ),
608 ]);
609 assert!(check(&base, &ours, &theirs).is_empty());
610 }
611
612 #[test]
613 fn a_new_definition_capturing_another_sides_call_is_shadow() {
614 let base = tree(&[
615 ("a.py", "def existing():\n return 0\n"),
616 ("b.py", "def report():\n return 1\n"),
617 ]);
618 let ours = tree(&[
620 (
621 "a.py",
622 "def existing():\n return 0\n\n\ndef render(x):\n return x\n",
623 ),
624 ("b.py", "def report():\n return 1\n"),
625 ]);
626 let theirs = tree(&[
628 ("a.py", "def existing():\n return 0\n"),
629 ("b.py", "def report():\n return render(1)\n"),
630 ]);
631 let docs = check(&base, &ours, &theirs);
632 assert_eq!(docs.len(), 1);
633 assert_eq!(docs[0].findings[0].class, "SHADOW");
634 assert_eq!(docs[0].findings[0].entity, "render");
635 }
636
637 #[test]
638 fn unsupported_files_are_skipped_rather_than_guessed_at() {
639 let base = tree(&[("a.bin", "def fetch_user(uid): pass\n")]);
640 let ours = tree(&[("a.bin", "def get_user(uid): pass\n")]);
641 let theirs = tree(&[("a.bin", "def fetch_user(uid): pass\nfetch_user(1)\n")]);
642 assert!(check(&base, &ours, &theirs).is_empty());
643 }
644
645 #[test]
646 fn documents_conform_to_the_schemas_required_keys() {
647 let base = tree(&[
648 ("a.py", "def fetch_user(uid):\n return uid\n"),
649 ("b.py", "def report():\n return 1\n"),
650 ]);
651 let ours = tree(&[
652 ("a.py", "def get_user(uid):\n return uid\n"),
653 ("b.py", "def report():\n return 1\n"),
654 ]);
655 let theirs = tree(&[
656 ("a.py", "def fetch_user(uid):\n return uid\n"),
657 ("b.py", "def report():\n return fetch_user(1)\n"),
658 ]);
659 let docs = check(&base, &ours, &theirs);
660 let v = serde_json::to_value(&docs[0]).unwrap();
661 for key in [
662 "schema_version",
663 "file",
664 "clean",
665 "confidence",
666 "findings",
667 "ops",
668 "bindings",
669 ] {
670 assert!(v.get(key).is_some(), "missing required key {key}");
671 }
672 assert_eq!(v["scope"], "repo");
673 assert_eq!(v["schema_version"], "1.1.0");
674 }
675}