1use std::collections::{BTreeMap, BTreeSet, HashMap};
65
66use sem_core::model::change::ChangeType;
67use sem_core::model::entity::SemanticEntity;
68use sem_core::model::identity::match_entities;
69
70use crate::parsers::{defined_names_of, entities_of, is_code, is_supported, oversize};
71use crate::repo_scope::Tree;
72use weave_core::binding::{
73 has_binding, has_call_reference, has_declaration, has_value_reference, identifiers,
74 import_bindings, may_mention,
75};
76use weave_core::verify::{
81 carries_identity, definition_key, duplication_ceiling, line_counts as counts, significant,
82 unanimity_floor, DefinitionKey,
83};
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Finding {
88 pub class: &'static str,
91 pub detail: String,
92 pub suggestion: Option<String>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Advisory {
103 pub class: &'static str,
106 pub entity: String,
108 pub entity_type: String,
109 pub detail: String,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Verdict {
118 pub file: String,
119 pub findings: Vec<Finding>,
120 pub advisories: Vec<Advisory>,
121 pub note: Option<String>,
125}
126
127impl Verdict {
128 pub fn noted(file: &str, note: &str) -> Verdict {
132 Verdict {
133 file: file.to_string(),
134 findings: Vec::new(),
135 advisories: Vec::new(),
136 note: Some(note.to_string()),
137 }
138 }
139
140 pub fn unread(file: &str, why: &str) -> Verdict {
144 Verdict {
145 file: file.to_string(),
146 findings: vec![Finding {
147 class: "UNREAD",
148 detail: format!("NOT CHECKED — a merge stage could not be read ({why})"),
149 suggestion: Some(
150 "fetch the missing objects (in a partial clone: `git fetch` from the \
151 promisor remote) and run `weave check` again"
152 .to_string(),
153 ),
154 }],
155 advisories: Vec::new(),
156 note: None,
157 }
158 }
159
160 pub fn ok(&self) -> bool {
163 self.findings.is_empty()
164 }
165
166 pub fn line(&self) -> String {
168 if let (true, Some(note)) = (self.ok(), &self.note) {
169 return format!("OK: {} — {note}", self.file);
170 }
171 if self.ok() {
172 return format!(
173 "OK: {} — markers cleared, no unanimous-line loss, no duplicated \
174 definitions or lines, no dangling references",
175 self.file
176 );
177 }
178 let details: Vec<&str> = self.findings.iter().map(|f| f.detail.as_str()).collect();
179 format!("FOUND: {} — {}", self.file, details.join("; "))
180 }
181}
182
183#[derive(Debug, Clone)]
189pub struct Report {
190 pub scope: String,
192 pub verdicts: Vec<Verdict>,
193}
194
195pub const NOTHING_TO_CHECK: &str = "no merge, rebase, cherry-pick or revert in progress and HEAD \
197 is not a merge commit, so there is no three-way context to verify a resolution against. \
198 NOTHING WAS CHECKED — this is not a clean bill of health.";
199
200pub fn check_in_progress(
203 dir: &std::path::Path,
204) -> Result<Option<Report>, Box<dyn std::error::Error>> {
205 let Some(scope) = crate::gitscan::merge_scope(dir)? else {
206 return Ok(None);
207 };
208 let mut verdicts = check(
209 &scope.base,
210 &scope.ours,
211 &scope.theirs,
212 &scope.work,
213 &scope.subjects,
214 );
215 verdicts.extend(
216 scope
217 .unreadable
218 .iter()
219 .map(|(file, why)| Verdict::unread(file, why)),
220 );
221 verdicts.extend(scope.irregular.iter().map(|file| {
222 Verdict::noted(
223 file,
224 "a symlink or submodule in a merge stage — not source, so git's guarantees stand",
225 )
226 }));
227 verdicts.sort_by(|a, b| a.file.cmp(&b.file));
228 Ok(Some(Report {
229 scope: match oversize_note(&scope.work) {
230 Some(note) => format!("{}; {note}", scope.scope),
231 None => scope.scope,
232 },
233 verdicts,
234 }))
235}
236
237impl Report {
238 pub fn to_json(&self) -> serde_json::Value {
240 serde_json::json!({
241 "scope": self.scope,
242 "files": self.verdicts.iter().map(|v| serde_json::json!({
243 "file": v.file,
244 "ok": v.ok(),
245 "verdict": v.line(),
246 "findings": v.findings.iter().map(|f| serde_json::json!({
247 "class": f.class,
248 "detail": f.detail,
249 "suggestion": f.suggestion,
250 })).collect::<Vec<_>>(),
251 "advisories": v.advisories.iter().map(|a| serde_json::json!({
254 "class": a.class,
255 "entity": a.entity,
256 "entity_type": a.entity_type,
257 "detail": a.detail,
258 })).collect::<Vec<_>>(),
259 })).collect::<Vec<_>>(),
260 })
261 }
262
263 pub fn tally(&self) -> (usize, usize, usize) {
267 let mut clean = 0;
268 let mut dirty = 0;
269 let mut findings = 0;
270 for v in &self.verdicts {
271 if v.ok() {
272 clean += 1;
273 } else {
274 dirty += 1;
275 }
276 findings += v.findings.len();
277 }
278 (clean, dirty, findings)
279 }
280
281 pub fn render(&self) -> String {
283 let mut out = format!("weave check: {}\n", self.scope);
284 if self.verdicts.is_empty() {
285 out.push_str(
286 "weave check: NOTHING WAS VERIFIED. This is not a clean bill of health — \
287 weave had no file to look at.\n",
288 );
289 return out;
290 }
291 for v in &self.verdicts {
292 out.push_str(&v.line());
293 out.push('\n');
294 for f in &v.findings {
295 if let Some(s) = &f.suggestion {
296 out.push_str(&format!(" suggestion ({}): {}\n", f.class, s));
297 }
298 }
299 for a in &v.advisories {
303 out.push_str(&match a.class {
304 "COOCCUPANCY" => format!(
305 " review (COOCCUPANCY): cleanly merged {} `{}` — {}; confirm they are meant to coexist\n",
306 a.entity_type, a.entity, a.detail
307 ),
308 class => format!(" review ({class}): {}\n", a.detail),
309 });
310 }
311 }
312 let (good, bad, findings) = self.tally();
313 if bad == 0 {
314 out.push_str(&format!(
315 "weave check: {good} file(s) verified against the three merge stages — \
316 every line both sides kept is present, nothing is stated more often than \
317 either side stated it, every reference still resolves. Re-reading these \
318 files end to end will not find anything this did not.\n"
319 ));
320 } else {
321 out.push_str(&format!(
322 "weave check: {good} file(s) clean, {bad} file(s) with findings \
323 ({findings} total). Fix the files above; the clean ones need no further \
324 reading.\n"
325 ));
326 }
327 out
328 }
329}
330
331fn verify_file(
337 file: &str,
338 base: Option<&str>,
339 ours: Option<&str>,
340 theirs: Option<&str>,
341 work: &str,
342) -> Vec<Finding> {
343 let mut out = Vec::new();
344
345 let marker_lines = weave_core::frame::marker_line_count(work);
347 if marker_lines > 0 {
348 out.push(Finding {
349 class: "MARKERS",
350 detail: format!("{marker_lines} conflict marker line(s) still present"),
351 suggestion: Some(
352 "the file is not resolved — every opening, separator and closing marker \
353 line must go, along with the refused_by: comment inside each box"
354 .to_string(),
355 ),
356 });
357 }
358 if work.lines().any(weave_core::conflict::is_teach_line) {
359 out.push(Finding {
360 class: "MARKERS",
361 detail: "weave's teach line is still in the file".to_string(),
362 suggestion: Some(
363 "delete the trailing `weave: run 'weave explain …'` comment; it is marker \
364 furniture, not source"
365 .to_string(),
366 ),
367 });
368 }
369
370 if let (Some(b), Some(o), Some(t)) = (base, ours, theirs) {
382 let (cb, co, ct, cw) = (counts(b), counts(o), counts(t), counts(work));
383 let mut lost: Vec<(&str, usize)> = cb
384 .iter()
385 .filter_map(|(line, n)| {
386 let required = unanimity_floor(
387 *n,
388 co.get(line).copied().unwrap_or(0),
389 ct.get(line).copied().unwrap_or(0),
390 );
391 let found = cw.get(line).copied().unwrap_or(0);
392 (found < required).then(|| (*line, required - found))
397 })
398 .collect();
399 lost.sort();
400 if !lost.is_empty() {
401 let total: usize = lost.iter().map(|(_, n)| n).sum();
402 out.push(Finding {
403 class: "LOSS",
404 detail: format!(
405 "{total} line(s) BOTH sides kept are missing, e.g. `{}`",
406 clip(lost[0].0)
407 ),
408 suggestion: Some(format!(
409 "restore the {} distinct line(s) neither side deleted; nothing in this \
410 merge licenses dropping them",
411 lost.len()
412 )),
413 });
414 }
415 }
416
417 {
425 let cw = counts(work);
426 let (cb, co, ct) = (
427 counts(base.unwrap_or("")),
428 counts(ours.unwrap_or("")),
429 counts(theirs.unwrap_or("")),
430 );
431 let allowance = |line: &str| {
437 duplication_ceiling(
438 cb.get(line).copied().unwrap_or(0),
439 co.get(line).copied().unwrap_or(0),
440 ct.get(line).copied().unwrap_or(0),
441 true,
442 )
443 };
444 let over: BTreeSet<&str> = cw
445 .iter()
446 .filter(|(line, found)| **found > allowance(line))
447 .map(|(line, _)| *line)
448 .collect();
449 let mut in_a_run: BTreeSet<&str> = BTreeSet::new();
454 {
455 let lines: Vec<&str> = work.lines().map(str::trim).collect();
456 for pair in lines.windows(2) {
457 if over.contains(pair[0]) && over.contains(pair[1]) {
458 in_a_run.insert(pair[0]);
459 in_a_run.insert(pair[1]);
460 }
461 }
462 }
463 let mut dup: Vec<(&str, usize, usize)> = over
464 .iter()
465 .filter(|line| carries_identity(line) || in_a_run.contains(*line))
466 .map(|line| (*line, cw[line], allowance(line)))
467 .collect();
468 dup.sort();
469 if !dup.is_empty() {
470 out.push(Finding {
471 class: "DUP",
472 detail: format!(
473 "{} line(s) appear more often than any version states them, e.g. `{}` \
474 ({}x, at most {}x in base/ours/theirs)",
475 dup.len(),
476 clip(dup[0].0),
477 dup[0].1,
478 dup[0].2
479 ),
480 suggestion: Some(
481 "delete the extra copies — a line stated twice usually means one side's \
482 edit landed both inside and outside a marker"
483 .to_string(),
484 ),
485 });
486 }
487 }
488
489 if let Some(u) = weave_core::verify::structured_data(ours, theirs, work, file) {
493 let loads = weave_core::datafile::keys(file, work).is_some_and(|r| r.is_ok());
494 out.push(Finding {
495 class: if loads { "DUP" } else { "PARSE" },
496 detail: u.detail,
497 suggestion: Some(if loads {
498 "keep one entry for the key, with the settings both sides need".to_string()
499 } else {
500 "make the file load again; a side's version of it does".to_string()
501 }),
502 });
503 }
504
505 if let (Some(o), Some(t)) = (ours, theirs) {
509 if let Some(u) =
510 weave_core::verify::duplicate_keys(file, o, t, work, &crate::parsers::REGISTRY)
511 {
512 out.push(Finding {
513 class: "DUP",
514 detail: u.detail,
515 suggestion: Some(
516 "keep one element for the key, combining what both sides need; two \
517 elements for one key do not compile in some languages and silently \
518 drop one side's in others"
519 .to_string(),
520 ),
521 });
522 }
523 }
524
525 let mut seen: BTreeMap<DefinitionKey, (String, usize)> = BTreeMap::new();
530 for e in entities_of(file, work) {
531 let Some(key) = definition_key(file, &e) else {
532 continue;
533 };
534 let slot = seen.entry(key).or_insert_with(|| (e.name.clone(), 0));
535 slot.1 += 1;
536 }
537 let dup_defs: Vec<&(String, usize)> = seen.values().filter(|(_, n)| *n > 1).collect();
538 if !dup_defs.is_empty() {
539 out.push(Finding {
540 class: "DUP",
541 detail: format!(
542 "{} name(s) are defined more than once: {}",
543 dup_defs.len(),
544 dup_defs
545 .iter()
546 .take(4)
547 .map(|(n, c)| format!("`{n}` ({c}x)"))
548 .collect::<Vec<_>>()
549 .join(", ")
550 ),
551 suggestion: Some(
552 "keep one definition of each name; the later one shadows the earlier and \
553 the earlier one's edits are silently dead"
554 .to_string(),
555 ),
556 });
557 }
558
559 out
560}
561fn clip(l: &str) -> String {
562 let t = l.trim();
563 if t.chars().count() > 72 {
564 t.chars().take(71).chain(['…']).collect()
565 } else {
566 t.to_string()
567 }
568}
569
570pub fn check(
582 base: &Tree,
583 ours: &Tree,
584 theirs: &Tree,
585 work: &Tree,
586 subjects: &[String],
587) -> Vec<Verdict> {
588 let defined_now: BTreeSet<String> = work
599 .iter()
600 .filter(|(p, _)| is_supported(p))
601 .flat_map(|(p, c)| -> Vec<String> {
602 if !is_code(p) {
603 return entities_of(p, c).into_iter().map(|e| e.name).collect();
604 }
605 defined_names_of(p, c)
606 .into_iter()
607 .flat_map(|name| {
608 let member = name
609 .rsplit_once("::")
610 .map(|(_, member)| member.to_string())
611 .filter(|m| !m.is_empty());
612 std::iter::once(name).chain(member)
613 })
614 .collect()
615 })
616 .collect();
617
618 let mut gone: BTreeSet<String> = BTreeSet::new();
628 let mut base_entities: BTreeMap<&str, Vec<SemanticEntity>> = BTreeMap::new();
629 for (i, stage) in [base, ours, theirs].into_iter().enumerate() {
630 for (p, c) in stage.iter().filter(|(p, _)| is_code(p)) {
631 let entities = entities_of(p, c);
632 for e in &entities {
633 if !defined_now.contains(&e.name) {
634 gone.insert(e.name.clone());
635 }
636 }
637 if i == 0 && !entities.is_empty() {
638 base_entities.insert(p.as_str(), entities);
639 }
640 }
641 }
642 for (_, c) in work.iter().filter(|(p, c)| is_code(p) && oversize(c)) {
644 let ids = identifiers(c);
645 gone.retain(|n| !(may_mention(&ids, c, n) && has_declaration(c, n)));
646 }
647
648 let renames = renames(base, ours, theirs, subjects);
649 let relocated = relocations(base, ours, theirs, work, subjects);
650 let mut successors = Successors {
651 base: &base_entities,
652 work,
653 memo: BTreeMap::new(),
654 };
655
656 let moddel = ModDelContext {
657 base,
658 ours,
659 theirs,
660 work,
661 subjects,
662 defined_now: &defined_now,
663 };
664 let mut verdicts = Vec::new();
665 for file in subjects {
666 let Some(w) = work.get(file) else {
667 if let Some(to) = relocated
668 .iter()
669 .find_map(|(to, (from, _))| (from == file).then_some(to))
670 {
671 verdicts.push(Verdict::noted(
672 file,
673 &format!(
674 "not in the working tree: git moved it to `{to}`, where the directory \
675 it was added to was renamed"
676 ),
677 ));
678 continue;
679 }
680 if let Some(md) = ModDel::of(file, base, ours, theirs) {
681 verdicts.push(moddel.deleted(file, &md));
682 continue;
683 }
684 verdicts.push(
685 match missing_file_finding(file, base, ours, theirs, work, &renames) {
686 Some(finding) => Verdict {
687 file: file.clone(),
688 findings: vec![finding],
689 advisories: Vec::new(),
690 note: None,
691 },
692 None => Verdict::noted(
693 file,
694 "not in the working tree, as the merge asked (one side deleted or \
695 renamed it and the other left it alone)",
696 ),
697 },
698 );
699 continue;
700 };
701 let (b, o, t) = match relocated.get(file) {
702 Some((from, _)) => (
703 None,
704 ours.get(from).map(String::as_str),
705 theirs.get(from).map(String::as_str),
706 ),
707 None => stages(file, base, ours, theirs, &renames),
708 };
709 let mut findings = verify_file(file, b, o, t, w);
710 if is_code(file) {
711 findings.extend(dangling(w, &gone, &mut successors));
712 findings.extend(dangling_imports(file, w, [b, o, t]));
713 }
714 let mut advisories = advisories_for(file, b, o, t);
715 if let Some(md) = ModDel::of(file, base, ours, theirs) {
716 let (f, a) = moddel.kept(file, w, &md);
717 findings.extend(f);
718 advisories.extend(a);
719 }
720 verdicts.push(Verdict {
721 file: file.clone(),
722 findings,
723 advisories,
724 note: None,
725 });
726 }
727 verdicts
728}
729
730pub fn oversize_note(work: &Tree) -> Option<String> {
733 let n = work
734 .iter()
735 .filter(|(p, c)| is_supported(p) && oversize(c))
736 .count();
737 (n > 0).then(|| {
738 format!(
739 "{n} file(s) over {} bytes were read for names lexically, not parsed for structure",
740 weave_core::merge::STRUCTURE_LIMIT_BYTES
741 )
742 })
743}
744
745fn renames(
758 base: &Tree,
759 ours: &Tree,
760 theirs: &Tree,
761 subjects: &[String],
762) -> BTreeMap<String, (String, Side)> {
763 let mut out = BTreeMap::new();
764 let added: Vec<(&String, Side)> = subjects
765 .iter()
766 .filter(|f| !base.contains_key(*f))
767 .filter_map(|f| match (ours.get(f), theirs.get(f)) {
768 (Some(_), None) => Some((f, Side::Ours)),
769 (None, Some(_)) => Some((f, Side::Theirs)),
770 _ => None,
771 })
772 .collect();
773 if added.is_empty() {
774 return out;
775 }
776 let mut index: HashMap<&str, Vec<(&str, usize)>> = HashMap::new();
779 let mut sizes: HashMap<&str, usize> = HashMap::new();
780 for (p, old) in base {
781 let mut copies: HashMap<&str, usize> = HashMap::new();
782 for l in old.lines().map(str::trim).filter(|l| significant(l)) {
783 *copies.entry(l).or_insert(0) += 1;
784 }
785 sizes.insert(p.as_str(), copies.values().sum());
786 for (l, n) in copies {
787 index.entry(l).or_default().push((p.as_str(), n));
788 }
789 }
790 for (file, side) in added {
791 let renamer = side.of(ours, theirs);
792 let lines_b: BTreeSet<&str> = renamer[file]
793 .lines()
794 .map(str::trim)
795 .filter(|l| significant(l))
796 .collect();
797 let mut kept: HashMap<&str, usize> = HashMap::new();
798 for l in &lines_b {
799 for (p, n) in index.get(l).into_iter().flatten() {
800 *kept.entry(p).or_insert(0) += n;
801 }
802 }
803 let best = kept
804 .into_iter()
805 .filter(|(p, _)| *p != file.as_str() && !renamer.contains_key(*p))
806 .map(|(p, k)| (k as f64 / sizes[p].max(lines_b.len()) as f64, p))
807 .filter(|(score, _)| *score >= 0.5)
808 .max_by(|a, b| a.0.total_cmp(&b.0).then_with(|| a.1.cmp(b.1)));
809 if let Some((_, old_path)) = best {
810 out.insert(file.clone(), (old_path.to_string(), side));
811 }
812 }
813 out
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817enum Side {
818 Ours,
819 Theirs,
820}
821
822impl Side {
823 fn of<'a>(self, ours: &'a Tree, theirs: &'a Tree) -> &'a Tree {
824 match self {
825 Side::Ours => ours,
826 Side::Theirs => theirs,
827 }
828 }
829}
830
831fn relocations(
842 base: &Tree,
843 ours: &Tree,
844 theirs: &Tree,
845 work: &Tree,
846 subjects: &[String],
847) -> BTreeMap<String, (String, Side)> {
848 let name = |p: &str| p.rsplit('/').next().unwrap_or(p).to_string();
849 let mut added: HashMap<String, Vec<(&String, Side)>> = HashMap::new();
850 for q in subjects {
851 if base.contains_key(q) || work.contains_key(q) {
852 continue;
853 }
854 let side = match (ours.get(q), theirs.get(q)) {
855 (Some(_), None) => Side::Ours,
856 (None, Some(_)) => Side::Theirs,
857 _ => continue,
858 };
859 added.entry(name(q)).or_default().push((q, side));
860 }
861 let mut out = BTreeMap::new();
862 for p in subjects {
863 let Some(w) = work.get(p) else { continue };
864 if base.contains_key(p) || ours.contains_key(p) || theirs.contains_key(p) {
865 continue;
866 }
867 let same: Vec<&(&String, Side)> = added
868 .get(&name(p))
869 .into_iter()
870 .flatten()
871 .filter(|(q, side)| side.of(ours, theirs).get(*q) == Some(w))
872 .collect();
873 if let [(q, side)] = same.as_slice() {
874 out.insert(p.clone(), ((*q).clone(), *side));
875 }
876 }
877 out
878}
879
880fn stages<'a>(
882 file: &str,
883 base: &'a Tree,
884 ours: &'a Tree,
885 theirs: &'a Tree,
886 renames: &BTreeMap<String, (String, Side)>,
887) -> (Option<&'a str>, Option<&'a str>, Option<&'a str>) {
888 let at = |tree: &'a Tree, path: &str| tree.get(path).map(String::as_str);
889 match renames.get(file) {
890 Some((old, Side::Theirs)) => (at(base, old), at(ours, old), at(theirs, file)),
891 Some((old, Side::Ours)) => (at(base, old), at(ours, file), at(theirs, old)),
892 None => (at(base, file), at(ours, file), at(theirs, file)),
893 }
894}
895
896fn missing_file_finding(
903 file: &str,
904 base: &Tree,
905 ours: &Tree,
906 theirs: &Tree,
907 work: &Tree,
908 renames: &BTreeMap<String, (String, Side)>,
909) -> Option<Finding> {
910 let renamed_away = renames
911 .iter()
912 .any(|(new, (old, _))| old == file && work.contains_key(new));
913 if renamed_away {
914 return None;
915 }
916 let (b, o, t) = (base.get(file), ours.get(file), theirs.get(file));
917 let detail = match (b, o, t) {
918 (Some(_), None, None) => return None,
919 (Some(b), None, Some(t)) | (Some(b), Some(t), None) if b == t => return None,
920 (Some(_), None, Some(_)) | (Some(_), Some(_), None) => return None,
922 (_, Some(_), Some(_)) => {
923 "the file is not in the working tree, but both sides kept it (deleted during the \
924 merge)"
925 }
926 (None, _, _) => "the file is not in the working tree, but a side added it",
927 };
928 Some(Finding {
929 class: "MISSING",
930 detail: detail.to_string(),
931 suggestion: None,
932 })
933}
934
935struct ModDel<'a> {
938 base: &'a str,
939 modified: &'a str,
941 deleter: Side,
943}
944
945impl<'a> ModDel<'a> {
946 fn of(file: &str, base: &'a Tree, ours: &'a Tree, theirs: &'a Tree) -> Option<Self> {
947 let b = base.get(file)?;
948 let (modified, deleter) = match (ours.get(file), theirs.get(file)) {
949 (None, Some(t)) => (t, Side::Ours),
950 (Some(o), None) => (o, Side::Theirs),
951 _ => return None,
952 };
953 (modified != b).then_some(ModDel {
954 base: b,
955 modified,
956 deleter,
957 })
958 }
959
960 fn sides(&self) -> (&'static str, &'static str) {
961 match self.deleter {
962 Side::Ours => ("ours", "theirs"),
963 Side::Theirs => ("theirs", "ours"),
964 }
965 }
966}
967
968enum Port {
970 NoSuccessor,
972 Present(String),
974 Portable(String, String),
977 Conflicts(String),
979}
980
981struct ModDelContext<'a> {
1012 base: &'a Tree,
1013 ours: &'a Tree,
1014 theirs: &'a Tree,
1015 work: &'a Tree,
1016 subjects: &'a [String],
1017 defined_now: &'a BTreeSet<String>,
1018}
1019
1020impl ModDelContext<'_> {
1021 fn deleted(&self, file: &str, md: &ModDel) -> Verdict {
1022 let (deleter, modifier) = md.sides();
1023 let mut findings = Vec::new();
1024 let mut advisories = Vec::new();
1025 let advise = |detail: String| Advisory {
1026 class: "MODDEL",
1027 entity: file.to_string(),
1028 entity_type: "file".to_string(),
1029 detail,
1030 };
1031
1032 if is_code(file) {
1034 let mut names: BTreeSet<String> = BTreeSet::new();
1035 for text in [md.base, md.modified] {
1036 names.extend(
1037 entities_of(file, text)
1038 .into_iter()
1039 .map(|e| e.name)
1040 .filter(|n| n.len() >= 3 && !self.defined_now.contains(n)),
1041 );
1042 }
1043 if let Some((name, caller)) = self.called_somewhere(&names) {
1044 findings.push(Finding {
1045 class: "MODDEL",
1046 detail: format!(
1047 "{deleter} deleted this file, but `{caller}` still calls `{name}`, which \
1048 only this file defined (modify/delete)"
1049 ),
1050 suggestion: Some(format!(
1051 "keep the version {modifier} modified, or remove the calls to `{name}`"
1052 )),
1053 });
1054 }
1055 }
1056
1057 if weave_core::layout::layout_equal(md.base, md.modified, file) {
1059 advisories.push(advise(format!(
1060 "deleted as {deleter} asked; the change {modifier} made to it was layout only \
1061 (whitespace), so nothing it said is lost"
1062 )));
1063 } else {
1064 match self.port(file, md) {
1065 Port::Present(to) => advisories.push(advise(format!(
1066 "deleted as {deleter} asked; {deleter} moved its content to `{to}`, which \
1067 already carries the edit {modifier} made"
1068 ))),
1069 Port::Portable(to, ported) => findings.push(Finding {
1070 class: "MODDEL",
1071 detail: format!(
1072 "{deleter} moved this file to `{to}` and {modifier} edited it here; the \
1073 edit is not in `{to}` (modify/delete)"
1074 ),
1075 suggestion: Some(format!(
1076 "the edit re-applies to `{to}` cleanly — apply this patch (`git apply`):\n{}",
1077 patch(&to, &self.work[&to], &ported)
1078 )),
1079 }),
1080 Port::Conflicts(to) => findings.push(Finding {
1081 class: "MODDEL",
1082 detail: format!(
1083 "{deleter} moved this file to `{to}` and {modifier} edited it here; the \
1084 edit is not in `{to}` and does not re-apply there cleanly (modify/delete)"
1085 ),
1086 suggestion: Some(format!(
1087 "carry the edit {modifier} made here into `{to}` by hand, or restore \
1088 this file"
1089 )),
1090 }),
1091 Port::NoSuccessor => findings.push(Finding {
1092 class: "MODDEL",
1093 detail: format!(
1094 "the file is not in the working tree: {deleter} deleted it but \
1095 {modifier} modified it, and the edit exists nowhere else \
1096 (modify/delete)"
1097 ),
1098 suggestion: Some(format!(
1099 "restore the version {modifier} modified if its edit should survive"
1100 )),
1101 }),
1102 }
1103 }
1104 Verdict {
1105 file: file.to_string(),
1106 findings,
1107 advisories,
1108 note: None,
1109 }
1110 }
1111
1112 fn kept(&self, file: &str, w: &str, md: &ModDel) -> (Vec<Finding>, Vec<Advisory>) {
1113 let (deleter, _) = md.sides();
1114 if let Some(to) = self.successor(file, md) {
1115 let here: BTreeSet<String> = entities_of(file, w)
1116 .into_iter()
1117 .filter(|e| definition_key(file, e).is_some())
1118 .map(|e| e.name)
1119 .collect();
1120 let there: BTreeSet<String> = entities_of(&to, &self.work[&to])
1121 .into_iter()
1122 .filter(|e| definition_key(&to, e).is_some())
1123 .map(|e| e.name)
1124 .collect();
1125 let twice: Vec<String> = here.intersection(&there).take(4).cloned().collect();
1126 if !twice.is_empty() {
1127 let listed = twice
1128 .iter()
1129 .map(|n| format!("`{n}`"))
1130 .collect::<Vec<_>>()
1131 .join(", ");
1132 return (
1133 vec![Finding {
1134 class: "MODDEL",
1135 detail: format!(
1136 "{deleter} moved this file to `{to}`, and keeping it here defines \
1137 {listed} in both (modify/delete)"
1138 ),
1139 suggestion: Some(format!(
1140 "delete this file and carry the edit into `{to}` instead"
1141 )),
1142 }],
1143 Vec::new(),
1144 );
1145 }
1146 }
1147 (
1148 Vec::new(),
1149 vec![Advisory {
1150 class: "MODDEL",
1151 entity: file.to_string(),
1152 entity_type: "file".to_string(),
1153 detail: format!(
1154 "kept although {deleter} deleted it — whether the file should exist is the \
1155 authors' call; nothing it defines is duplicated elsewhere"
1156 ),
1157 }],
1158 )
1159 }
1160
1161 fn called_somewhere(&self, names: &BTreeSet<String>) -> Option<(String, String)> {
1163 if names.is_empty() {
1164 return None;
1165 }
1166 for (path, c) in self.work.iter().filter(|(p, _)| is_code(p)) {
1167 let ids = identifiers(c);
1168 for n in names {
1169 if may_mention(&ids, c, n) && !has_declaration(c, n) && has_call_reference(c, n) {
1170 return Some((n.clone(), path.clone()));
1171 }
1172 }
1173 }
1174 None
1175 }
1176
1177 fn successor(&self, file: &str, md: &ModDel) -> Option<String> {
1180 let deleter_tree = md.deleter.of(self.ours, self.theirs);
1181 let mut want: HashMap<&str, usize> = HashMap::new();
1182 for l in md.base.lines().map(str::trim).filter(|l| significant(l)) {
1183 *want.entry(l).or_insert(0) += 1;
1184 }
1185 let size = want.values().sum::<usize>();
1186 if size == 0 {
1187 return None;
1188 }
1189 let scored: Vec<(f64, &String)> = self
1190 .subjects
1191 .iter()
1192 .filter(|p| p.as_str() != file && self.work.contains_key(*p))
1193 .filter(|p| match (deleter_tree.get(*p), self.base.get(*p)) {
1194 (Some(d), Some(b)) => d != b,
1195 (Some(_), None) => true,
1196 (None, _) => false,
1197 })
1198 .map(|p| {
1199 let mut have: HashMap<&str, usize> = HashMap::new();
1200 for l in self.work[p]
1201 .lines()
1202 .map(str::trim)
1203 .filter(|l| significant(l))
1204 {
1205 *have.entry(l).or_insert(0) += 1;
1206 }
1207 let shared: usize = want
1208 .iter()
1209 .map(|(l, n)| (*n).min(have.get(l).copied().unwrap_or(0)))
1210 .sum();
1211 let theirs = have.values().sum::<usize>();
1212 (shared as f64 / size.max(theirs) as f64, p)
1213 })
1214 .collect();
1215 for tier in [0.5, 1.0 / 3.0, 0.2] {
1216 let at: Vec<&String> = scored
1217 .iter()
1218 .filter(|(s, _)| *s >= tier)
1219 .map(|(_, p)| *p)
1220 .collect();
1221 match at.len() {
1222 0 => continue,
1223 1 => return Some(at[0].clone()),
1224 _ => return None,
1225 }
1226 }
1227 None
1228 }
1229
1230 fn port(&self, file: &str, md: &ModDel) -> Port {
1231 let Some(to) = self.successor(file, md) else {
1232 return Port::NoSuccessor;
1233 };
1234 let now = &self.work[&to];
1235 let r = weave_core::entity_merge(md.base, now, md.modified, &to);
1236 if !r.is_clean() {
1237 return Port::Conflicts(to);
1238 }
1239 if weave_core::layout::layout_equal(&r.content, now, &to) {
1240 Port::Present(to)
1241 } else {
1242 Port::Portable(to, r.content)
1243 }
1244 }
1245}
1246
1247fn patch(path: &str, old: &str, new: &str) -> String {
1249 let body = diffy::create_patch(old, new).to_string();
1250 let hunks = body.find("@@").map(|i| &body[i..]).unwrap_or("");
1251 format!("--- a/{path}\n+++ b/{path}\n{hunks}")
1252}
1253
1254fn dangling_imports(file: &str, w: &str, stages: [Option<&str>; 3]) -> Vec<Finding> {
1268 if let [Some(b), Some(o), Some(t)] = stages {
1269 let parsed = [b, o, t, w].map(|text| entities_of(file, text));
1270 return weave_core::verify::dangling_use(
1271 file,
1272 [b, o, t, w],
1273 [&parsed[0], &parsed[1], &parsed[2], &parsed[3]],
1274 )
1275 .map(|n| {
1276 let import = [b, o, t]
1277 .iter()
1278 .flat_map(|s| s.lines())
1279 .find(|l| weave_core::verify::import_names(file, l).contains(&n));
1280 Finding {
1281 class: "DANGLING",
1282 detail: format!(
1283 "`{n}` is still used here but no longer declared or imported; a merge \
1284 stage bound it"
1285 ),
1286 suggestion: Some(match import {
1287 Some(line) => format!(
1288 "restore the import a merge stage had for `{n}`: `{}`",
1289 line.trim()
1290 ),
1291 None => format!(
1292 "restore the declaration of `{n}` a merge stage had, or remove its \
1293 uses"
1294 ),
1295 }),
1296 }
1297 })
1298 .into_iter()
1299 .collect();
1300 }
1301 let mut hits: BTreeMap<String, String> = BTreeMap::new();
1302 for stage in stages.into_iter().flatten() {
1303 for line in stage.lines() {
1304 for name in import_bindings(line) {
1305 if hits.contains_key(&name)
1306 || has_binding(w, &name)
1307 || !has_value_reference(w, &name)
1308 {
1309 continue;
1310 }
1311 hits.insert(name, line.trim().to_string());
1312 }
1313 }
1314 }
1315 if hits.is_empty() {
1316 return Vec::new();
1317 }
1318 let listed = hits
1319 .keys()
1320 .take(4)
1321 .map(|n| format!("`{n}`"))
1322 .collect::<Vec<_>>()
1323 .join(", ");
1324 let (first, line) = hits.iter().next().expect("non-empty");
1325 vec![Finding {
1326 class: "DANGLING",
1327 detail: format!(
1328 "{} name(s) are still used here but no longer imported: {listed}",
1329 hits.len()
1330 ),
1331 suggestion: Some(format!(
1332 "restore the import a merge stage had for `{first}`: `{line}`"
1333 )),
1334 }]
1335}
1336
1337fn advisories_for(
1344 file: &str,
1345 base: Option<&str>,
1346 ours: Option<&str>,
1347 theirs: Option<&str>,
1348) -> Vec<Advisory> {
1349 let (Some(b), Some(o), Some(t)) = (base, ours, theirs) else {
1350 return Vec::new();
1351 };
1352 weave_core::entity_merge(b, o, t, file)
1353 .warnings
1354 .iter()
1355 .filter_map(|w| match &w.kind {
1356 weave_core::validate::WarningKind::SiblingCoChange {
1357 ours_added,
1358 ours_changed,
1359 theirs_added,
1360 theirs_changed,
1361 } => Some(Advisory {
1362 class: "COOCCUPANCY",
1363 entity: w.entity_name.clone(),
1364 entity_type: w.entity_type.clone(),
1365 detail: format!(
1366 "both sides changed siblings ({}; {})",
1367 weave_core::validate::co_change_side_phrase("ours", ours_added, ours_changed),
1368 weave_core::validate::co_change_side_phrase(
1369 "theirs",
1370 theirs_added,
1371 theirs_changed
1372 ),
1373 ),
1374 }),
1375 _ => None,
1376 })
1377 .collect()
1378}
1379
1380fn dangling(w: &str, gone: &BTreeSet<String>, successors: &mut Successors) -> Vec<Finding> {
1393 let ids = identifiers(w);
1394 let mut hits: Vec<(String, Option<(String, String)>)> = Vec::new();
1395 for name in gone {
1396 if name.len() < 3
1397 || !may_mention(&ids, w, name)
1398 || has_declaration(w, name)
1399 || !has_call_reference(w, name)
1400 {
1401 continue;
1402 }
1403 hits.push((name.clone(), successors.of(name)));
1404 }
1405 if hits.is_empty() {
1406 return Vec::new();
1407 }
1408 let listed = hits
1409 .iter()
1410 .take(4)
1411 .map(|(n, _)| format!("`{n}`"))
1412 .collect::<Vec<_>>()
1413 .join(", ");
1414 let suggestion = hits.iter().find_map(|(n, s)| {
1415 s.as_ref().map(|(to, where_)| {
1416 format!(
1417 "`{n}` left `{where_}` and `{to}` arrived in its place — if that is the \
1418 rename, rewrite every call of `{n}` here to `{to}`"
1419 )
1420 })
1421 });
1422 vec![Finding {
1423 class: "DANGLING",
1424 detail: format!(
1425 "{} name(s) are still referenced here but defined nowhere in the working tree: {}",
1426 hits.len(),
1427 listed
1428 ),
1429 suggestion: suggestion
1430 .or_else(|| Some("restore the definition, or remove the call sites".to_string())),
1431 }]
1432}
1433
1434struct Successors<'a> {
1444 base: &'a BTreeMap<&'a str, Vec<SemanticEntity>>,
1446 work: &'a Tree,
1447 memo: BTreeMap<String, Option<(String, String)>>,
1448}
1449
1450impl Successors<'_> {
1451 fn of(&mut self, name: &str) -> Option<(String, String)> {
1452 if let Some(known) = self.memo.get(name) {
1453 return known.clone();
1454 }
1455 let found = self.find(name);
1456 self.memo.insert(name.to_string(), found.clone());
1457 found
1458 }
1459
1460 fn find(&self, name: &str) -> Option<(String, String)> {
1461 for (path, old) in self.base {
1462 if !old.iter().any(|e| e.name == name) {
1463 continue;
1464 }
1465 let Some(now) = self.work.get(*path) else {
1466 continue;
1467 };
1468 let new = entities_of(path, now);
1469 for c in match_entities(old, &new, path, None, None, None).changes {
1470 if c.change_type == ChangeType::Renamed
1471 && c.old_entity_name.as_deref() == Some(name)
1472 && c.entity_name != name
1473 && !c.entity_name.is_empty()
1474 {
1475 return Some((c.entity_name, path.to_string()));
1476 }
1477 }
1478 let before: BTreeSet<&str> = old.iter().map(|e| e.name.as_str()).collect();
1484 let after: BTreeSet<&str> = new.iter().map(|e| e.name.as_str()).collect();
1485 let gone: Vec<&&str> = before.difference(&after).collect();
1486 let arrived: Vec<&&str> = after.difference(&before).collect();
1487 if gone.len() == 1 && arrived.len() == 1 && *gone[0] == name {
1488 return Some((arrived[0].to_string(), path.to_string()));
1489 }
1490 }
1491 None
1492 }
1493}
1494
1495#[cfg(test)]
1496mod tests {
1497 use super::*;
1498
1499 fn tree(pairs: &[(&str, &str)]) -> Tree {
1500 pairs
1501 .iter()
1502 .map(|(p, c)| (p.to_string(), c.to_string()))
1503 .collect()
1504 }
1505
1506 const BASE: &str = "def a():\n total_count = 0\n return total_count\n\ndef keep():\n return 'a stable helper line'\n";
1507
1508 fn within<T: Send + 'static>(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T {
1511 let (tx, rx) = std::sync::mpsc::channel();
1512 std::thread::spawn(move || {
1513 let _ = tx.send(f());
1514 });
1515 rx.recv_timeout(std::time::Duration::from_secs(secs))
1516 .unwrap_or_else(|_| panic!("did not finish within {secs}s"))
1517 }
1518
1519 #[test]
1524 fn a_huge_data_file_elsewhere_in_the_repo_does_not_stall_the_check() {
1525 let mut xml = String::from("<records>\n");
1526 let mut i = 0;
1527 while xml.len() <= 3 * weave_core::merge::STRUCTURE_LIMIT_BYTES {
1528 xml.push_str(&format!(" <record><id>{i}</id></record>\n"));
1529 i += 1;
1530 }
1531 xml.push_str("</records>\n");
1532 let ours = BASE.replace("total_count = 0", "total_count = 1");
1533 let theirs = BASE.replace("return 'a stable", "return 'one stable");
1534 let merged = ours.replace("return 'a stable", "return 'one stable");
1535 let work = tree(&[("m.py", &merged), ("data/records.xml", &xml)]);
1536 let v = within(60, move || {
1537 check(
1538 &tree(&[("m.py", BASE)]),
1539 &tree(&[("m.py", &ours)]),
1540 &tree(&[("m.py", &theirs)]),
1541 &work,
1542 &["m.py".to_string()],
1543 )
1544 });
1545 assert!(v[0].ok(), "{:#?}", v[0]);
1546 let note = oversize_note(&tree(&[("data/records.xml", "")])).is_none();
1547 assert!(note, "a small file needs no note");
1548 }
1549
1550 #[test]
1553 fn a_name_declared_only_in_an_oversize_file_is_not_dangling() {
1554 let mut big = String::from("def load_all_rows():\n return []\n");
1555 while big.len() <= weave_core::merge::STRUCTURE_LIMIT_BYTES {
1556 big.push_str("# padding that makes this file too large to parse for structure\n");
1557 }
1558 let base = tree(&[
1559 ("a.py", "def load_all_rows():\n return []\n"),
1560 ("b.py", "def go():\n return load_all_rows()\n"),
1561 ]);
1562 let ours = tree(&[
1563 ("a.py", "def unrelated():\n return 1\n"),
1564 ("b.py", "def go():\n return load_all_rows()\n"),
1565 ]);
1566 let mut work = ours.clone();
1567 let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
1568 assert!(
1569 v[0].findings.iter().any(|f| f.class == "DANGLING"),
1570 "without a definition anywhere it dangles: {:#?}",
1571 v[0]
1572 );
1573 work.insert("vendor/big.py".to_string(), big);
1574 let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
1575 assert!(v[0].ok(), "{:#?}", v[0]);
1576 assert!(oversize_note(&work).is_some_and(|n| n.starts_with("1 file(s)")));
1577 }
1578
1579 #[test]
1585 fn the_dangling_pass_scales_with_the_merge_not_its_square() {
1586 let n = 1500;
1587 let file = |i: usize| format!("pkg/m{i:04}.py");
1588 let padding: String = (0..60)
1591 .map(|k| format!("# note {k}: an ordinary line of commentary in a module\n"))
1592 .collect();
1593 let body = |i: usize, name: &str| {
1594 format!(
1595 "def {name}(x):\n return x + {i}\n\n\ndef use_{i}():\n return helper_{}(1)\n{padding}",
1596 (i + 1) % n
1597 )
1598 };
1599 let base: Tree = (0..n)
1600 .map(|i| (file(i), body(i, &format!("helper_{i}"))))
1601 .collect();
1602 let ours: Tree = (0..n)
1603 .map(|i| (file(i), body(i, &format!("renamed_helper_{i}"))))
1604 .collect();
1605 let subjects: Vec<String> = (0..n).map(file).collect();
1606 let v = within(120, move || {
1607 let work = ours.clone();
1608 check(&base, &ours, &base, &work, &subjects)
1609 });
1610 assert_eq!(v.len(), n);
1611 assert!(
1612 v.iter()
1613 .all(|v| v.findings.iter().any(|f| f.class == "DANGLING")),
1614 "every caller of a renamed helper dangles"
1615 );
1616 }
1617
1618 #[test]
1619 fn a_data_key_both_sides_added_is_a_duplicate_and_a_broken_file_is_parse() {
1620 let base = "[deps]\nalpha = \"1\"\nbravo = \"1\"\n";
1621 let ours = "[deps]\nalpha = \"1\"\nkit = \"0.6\"\nbravo = \"1\"\n";
1622 let theirs = "[deps]\nalpha = \"1\"\nbravo = \"1\"\nkit = { version = \"0.6\" }\n";
1623 let both =
1624 "[deps]\nalpha = \"1\"\nkit = \"0.6\"\nbravo = \"1\"\nkit = { version = \"0.6\" }\n";
1625 let run = |work: &str| {
1626 check(
1627 &tree(&[("Cargo.toml", base)]),
1628 &tree(&[("Cargo.toml", ours)]),
1629 &tree(&[("Cargo.toml", theirs)]),
1630 &tree(&[("Cargo.toml", work)]),
1631 &["Cargo.toml".to_string()],
1632 )
1633 };
1634 let v = run(both);
1636 assert_eq!(findings_of(&v, "Cargo.toml")[0].class, "PARSE", "{v:#?}");
1637 assert!(run(ours).iter().all(Verdict::ok));
1638
1639 let base = "{\"a\": 1,\n \"b\": 2}\n";
1640 let ours = "{\"a\": 1,\n \"t\": 5,\n \"b\": 2}\n";
1641 let theirs = "{\"a\": 1,\n \"b\": 2,\n \"t\": 9}\n";
1642 let both = "{\"a\": 1,\n \"t\": 5,\n \"b\": 2,\n \"t\": 9}\n";
1643 let v = check(
1644 &tree(&[("p.json", base)]),
1645 &tree(&[("p.json", ours)]),
1646 &tree(&[("p.json", theirs)]),
1647 &tree(&[("p.json", both)]),
1648 &["p.json".to_string()],
1649 );
1650 let f = findings_of(&v, "p.json");
1651 assert!(
1652 f.iter()
1653 .any(|f| f.class == "DUP" && f.detail.contains("`t`")),
1654 "{v:#?}"
1655 );
1656 }
1657
1658 #[test]
1659 fn a_faithful_resolution_verifies_clean() {
1660 let ours = BASE.replace("total_count = 0", "total_count = 1");
1661 let theirs = BASE.replace("total_count = 0", "total_count = 2");
1662 let resolved = BASE.replace("total_count = 0", "total_count = 3");
1663 let v = check(
1664 &tree(&[("m.py", BASE)]),
1665 &tree(&[("m.py", &ours)]),
1666 &tree(&[("m.py", &theirs)]),
1667 &tree(&[("m.py", &resolved)]),
1668 &["m.py".to_string()],
1669 );
1670 assert!(v[0].ok(), "{:#?}", v[0]);
1671 assert!(v[0].line().starts_with("OK: m.py"));
1672 }
1673
1674 #[test]
1675 fn a_deliberately_lossy_resolution_is_caught() {
1676 let ours = BASE.replace("total_count = 0", "total_count = 1");
1677 let theirs = BASE.replace("total_count = 0", "total_count = 2");
1678 let lossy = "def a():\n total_count = 3\n return total_count\n";
1680 let v = check(
1681 &tree(&[("m.py", BASE)]),
1682 &tree(&[("m.py", &ours)]),
1683 &tree(&[("m.py", &theirs)]),
1684 &tree(&[("m.py", lossy)]),
1685 &["m.py".to_string()],
1686 );
1687 assert!(!v[0].ok(), "{:#?}", v[0]);
1688 assert!(
1689 v[0].findings.iter().any(|f| f.class == "LOSS"),
1690 "{:#?}",
1691 v[0]
1692 );
1693 }
1694
1695 #[test]
1696 fn markers_left_behind_are_the_first_thing_reported() {
1697 let conflicted = "def a():\n<<<<<<< ours — function `a`\n total_count = 1\n=======\n total_count = 2\n>>>>>>> theirs — function `a`\n return total_count\n\ndef keep():\n return 'a stable helper line'\n";
1698 let v = check(
1699 &tree(&[("m.py", BASE)]),
1700 &tree(&[("m.py", BASE)]),
1701 &tree(&[("m.py", BASE)]),
1702 &tree(&[("m.py", conflicted)]),
1703 &["m.py".to_string()],
1704 );
1705 assert_eq!(v[0].findings[0].class, "MARKERS", "{:#?}", v[0]);
1706 }
1707
1708 #[test]
1709 fn a_line_stated_more_often_than_either_side_states_it_is_a_duplicate() {
1710 let dup = "def a():\n total_count = 0\n total_count = 0\n return total_count\n\ndef keep():\n return 'a stable helper line'\n";
1711 let v = check(
1712 &tree(&[("m.py", BASE)]),
1713 &tree(&[("m.py", BASE)]),
1714 &tree(&[("m.py", BASE)]),
1715 &tree(&[("m.py", dup)]),
1716 &["m.py".to_string()],
1717 );
1718 assert!(
1719 v[0].findings.iter().any(|f| f.class == "DUP"),
1720 "{:#?}",
1721 v[0]
1722 );
1723 }
1724
1725 #[test]
1726 fn a_call_whose_definition_left_the_repo_is_dangling() {
1727 let base = tree(&[
1728 ("a.py", "def fetch_user(i):\n return i\n"),
1729 ("b.py", "def go():\n return fetch_user(1)\n"),
1730 ]);
1731 let ours = tree(&[
1732 ("a.py", "def get_user(i):\n return i\n"),
1733 ("b.py", "def go():\n return fetch_user(1)\n"),
1734 ]);
1735 let work = ours.clone();
1736 let v = check(&base, &ours, &base, &work, &["b.py".to_string()]);
1737 assert!(
1738 v[0].findings.iter().any(|f| f.class == "DANGLING"),
1739 "{:#?}",
1740 v[0]
1741 );
1742 }
1743
1744 #[test]
1745 fn a_clean_container_co_change_is_an_advisory_not_a_finding() {
1746 let base =
1750 "class C:\n def a(self):\n return 1\n\n def b(self):\n return 2\n";
1751 let ours = "class C:\n def a(self):\n return 1 + 10\n\n def b(self):\n return 2\n";
1752 let theirs = "class C:\n def a(self):\n return 1\n\n def b(self):\n return 2 + 20\n";
1753 let work = "class C:\n def a(self):\n return 1 + 10\n\n def b(self):\n return 2 + 20\n";
1754 let v = check(
1755 &tree(&[("m.py", base)]),
1756 &tree(&[("m.py", ours)]),
1757 &tree(&[("m.py", theirs)]),
1758 &tree(&[("m.py", work)]),
1759 &["m.py".to_string()],
1760 );
1761 assert!(
1762 v[0].ok(),
1763 "an advisory must not make the file not-OK: {:#?}",
1764 v[0]
1765 );
1766 assert!(
1767 v[0].findings.is_empty(),
1768 "advisory is not a finding: {:#?}",
1769 v[0]
1770 );
1771 assert_eq!(
1772 v[0].advisories.len(),
1773 1,
1774 "the co-change must be advised: {:#?}",
1775 v[0]
1776 );
1777 assert_eq!(v[0].advisories[0].entity, "C");
1778 let report = Report {
1780 scope: "test".to_string(),
1781 verdicts: v,
1782 };
1783 assert_eq!(report.tally().1, 0, "no files with findings");
1784 assert!(
1785 report.render().contains("review (COOCCUPANCY)"),
1786 "{}",
1787 report.render()
1788 );
1789 }
1790
1791 const MOVED_INTO_A_CLASS: [(&str, &str, &str, &str, &str); 4] = [
1795 (
1796 "sheet.cpp",
1797 "void add_row(int x) {\n total += x;\n}\n",
1798 "class Sheet {\n public:\n void add_row(int x) {\n total += x;\n }\n};\n",
1799 "fill.cpp",
1800 "void Sheet::fill() {\n add_row(1);\n}\n",
1801 ),
1802 (
1805 "sheet.cpp",
1806 "void add_row(int x) {\n total += x;\n}\n",
1807 "class Sheet {\n public:\n void add_row(int x);\n};\n\n\
1808 void Sheet::add_row(int x) {\n total += x;\n}\n",
1809 "fill.cpp",
1810 "void Sheet::fill() {\n add_row(1);\n}\n",
1811 ),
1812 (
1813 "rows.py",
1814 "def add_row(x):\n return x + 1\n",
1815 "class Sheet:\n def add_row(self, x):\n return x + 1\n",
1816 "fill.py",
1817 "def fill():\n return add_row(1)\n",
1818 ),
1819 (
1820 "sheet.ts",
1821 "export function addRow(x: number) {\n return x + 1;\n}\n",
1822 "export class Sheet {\n addRow(x: number) {\n return x + 1;\n }\n}\n",
1823 "fill.ts",
1824 "function fill() {\n return addRow(1);\n}\n",
1825 ),
1826 ];
1827
1828 fn check_move(
1829 defs: &str,
1830 base_def: &str,
1831 work_def: &str,
1832 caller: &str,
1833 call: &str,
1834 ) -> Vec<Verdict> {
1835 let base = tree(&[(defs, base_def), (caller, call)]);
1836 let ours = tree(&[(defs, work_def), (caller, call)]);
1837 check(
1838 &base,
1839 &ours,
1840 &base,
1841 &ours,
1842 &[defs.to_string(), caller.to_string()],
1843 )
1844 }
1845
1846 #[test]
1851 fn a_definition_moved_into_a_class_still_defines_its_name() {
1852 for (defs, base_def, work_def, caller, call) in MOVED_INTO_A_CLASS {
1853 let v = check_move(defs, base_def, work_def, caller, call);
1854 assert!(
1855 findings_of(&v, caller)
1856 .iter()
1857 .all(|f| f.class != "DANGLING"),
1858 "{defs} -> nested: {v:#?}"
1859 );
1860 let v = check_move(defs, work_def, base_def, caller, call);
1862 assert!(
1863 findings_of(&v, caller)
1864 .iter()
1865 .all(|f| f.class != "DANGLING"),
1866 "{defs} -> top level: {v:#?}"
1867 );
1868 }
1869 }
1870
1871 #[test]
1874 fn a_definition_deleted_outright_still_dangles() {
1875 let emptied = [
1876 (
1877 "sheet.cpp",
1878 "class Sheet {\n public:\n void other_row(int x) {\n total -= x;\n }\n};\n",
1879 "add_row",
1880 ),
1881 (
1882 "rows.py",
1883 "class Sheet:\n def other_row(self, x):\n return x - 1\n",
1884 "add_row",
1885 ),
1886 (
1887 "sheet.ts",
1888 "export class Sheet {\n otherRow(x: number) {\n return x - 1;\n }\n}\n",
1889 "addRow",
1890 ),
1891 ];
1892 for (defs, base_def, _, caller, call) in MOVED_INTO_A_CLASS {
1893 let (_, work_def, name) = emptied.iter().find(|(d, ..)| *d == defs).unwrap();
1894 let v = check_move(defs, base_def, work_def, caller, call);
1895 let dangling = findings_of(&v, caller)
1896 .into_iter()
1897 .find(|f| f.class == "DANGLING")
1898 .unwrap_or_else(|| panic!("{defs}: a deleted `{name}` must dangle: {v:#?}"));
1899 assert!(
1900 dangling.detail.contains(&format!("`{name}`")),
1901 "{dangling:#?}"
1902 );
1903 }
1904 }
1905
1906 #[test]
1909 fn a_nested_data_key_does_not_define_a_deleted_name() {
1910 let config = "{\n \"columns\": {\n \"add_row\": true\n }\n}\n";
1911 let (defs, base_def, _, caller, call) = MOVED_INTO_A_CLASS[2];
1912 let gone = "class Sheet:\n def other_row(self, x):\n return x - 1\n";
1913 let base = tree(&[(defs, base_def), (caller, call), ("config.json", config)]);
1914 let ours = tree(&[(defs, gone), (caller, call), ("config.json", config)]);
1915 let v = check(
1916 &base,
1917 &ours,
1918 &base,
1919 &ours,
1920 &[defs.to_string(), caller.to_string()],
1921 );
1922 assert!(
1923 findings_of(&v, caller)
1924 .iter()
1925 .any(|f| f.class == "DANGLING"),
1926 "{v:#?}"
1927 );
1928 }
1929
1930 fn findings_of<'a>(v: &'a [Verdict], file: &str) -> Vec<&'a Finding> {
1931 v.iter()
1932 .filter(|v| v.file == file)
1933 .flat_map(|v| v.findings.iter())
1934 .collect()
1935 }
1936
1937 const FLOW_BASE: &str = "\
1938import * as Alpha from \"./widget\"
1939import * as Beta from \"./gadget\"
1940import * as Widget from \"./widget\"
1941
1942export function* flow() {
1943 yield* Alpha.first()
1944 const spacer = 1
1945 yield* Widget.second()
1946}
1947";
1948
1949 #[test]
1952 fn a_type_whose_import_the_merge_dropped_is_dangling() {
1953 let base = "package p;\n\nimport a.model.Receipt;\nimport java.util.List;\n\npublic class S {\n public List<String> all() {\n return null;\n }\n}\n";
1956 let ours = base.replace("List<String>", "List<Receipt>");
1957 let theirs = base.replace("import a.model.Receipt;\n", "");
1958 let work = ours.replace("import a.model.Receipt;\n", "");
1959 let f = "S.java";
1960 let v = check(
1961 &tree(&[(f, base)]),
1962 &tree(&[(f, &ours)]),
1963 &tree(&[(f, &theirs)]),
1964 &tree(&[(f, &work)]),
1965 &[f.to_string()],
1966 );
1967 assert!(
1968 v[0].findings.iter().any(|f| f.class == "DANGLING"),
1969 "{:#?}",
1970 v[0]
1971 );
1972 let v = check(
1974 &tree(&[(f, base)]),
1975 &tree(&[(f, &ours)]),
1976 &tree(&[(f, &theirs)]),
1977 &tree(&[(f, &ours)]),
1978 &[f.to_string()],
1979 );
1980 assert!(
1981 !v[0].findings.iter().any(|f| f.class == "DANGLING"),
1982 "{:#?}",
1983 v[0]
1984 );
1985 }
1986
1987 #[test]
1988 fn a_namespace_whose_import_the_merge_dropped_is_dangling() {
1989 let ours = FLOW_BASE
1990 .replace("import * as Widget from \"./widget\"\n", "")
1991 .replace("Widget.second()", "Beta.second()");
1992 let theirs = FLOW_BASE
1993 .replace("import * as Alpha from \"./widget\"\n", "")
1994 .replace("Alpha.first()", "Widget.first()");
1995 let work = FLOW_BASE
1996 .replace("import * as Alpha from \"./widget\"\n", "")
1997 .replace("import * as Widget from \"./widget\"\n", "")
1998 .replace("Alpha.first()", "Widget.first()")
1999 .replace("Widget.second()", "Beta.second()");
2000 let v = check(
2001 &tree(&[("flow.ts", FLOW_BASE)]),
2002 &tree(&[("flow.ts", &ours)]),
2003 &tree(&[("flow.ts", &theirs)]),
2004 &tree(&[("flow.ts", &work)]),
2005 &["flow.ts".to_string()],
2006 );
2007 let dangling: Vec<_> = findings_of(&v, "flow.ts")
2008 .into_iter()
2009 .filter(|f| f.class == "DANGLING")
2010 .collect();
2011 assert_eq!(dangling.len(), 1, "{v:#?}");
2012 assert!(dangling[0].detail.contains("`Widget`"), "{v:#?}");
2013 assert!(!dangling[0].detail.contains("`Alpha`"), "{v:#?}");
2014 assert!(
2015 dangling[0]
2016 .suggestion
2017 .as_deref()
2018 .is_some_and(|s| s.contains("import * as Widget from \"./widget\"")),
2019 "{v:#?}"
2020 );
2021 let fixed = format!("import * as Widget from \"./widget\"\n{work}");
2023 let v = check(
2024 &tree(&[("flow.ts", FLOW_BASE)]),
2025 &tree(&[("flow.ts", &ours)]),
2026 &tree(&[("flow.ts", &theirs)]),
2027 &tree(&[("flow.ts", &fixed)]),
2028 &["flow.ts".to_string()],
2029 );
2030 assert!(v[0].ok(), "{v:#?}");
2031 }
2032
2033 #[test]
2034 fn a_file_one_side_deleted_and_the_other_left_alone_is_not_a_finding() {
2035 let gone = "export function unused() {\n return 1\n}\n";
2036 let v = check(
2037 &tree(&[("gone.ts", gone)]),
2038 &tree(&[("gone.ts", gone)]),
2039 &tree(&[]),
2040 &tree(&[]),
2041 &["gone.ts".to_string()],
2042 );
2043 assert!(findings_of(&v, "gone.ts").is_empty(), "{v:#?}");
2044 assert!(
2045 v[0].line()
2046 .starts_with("OK: gone.ts — not in the working tree"),
2047 "a deletion as asked still gets its line: {v:#?}"
2048 );
2049 let edited = gone.replace("return 1", "return 2");
2051 let v = check(
2052 &tree(&[("gone.ts", gone)]),
2053 &tree(&[("gone.ts", &edited)]),
2054 &tree(&[]),
2055 &tree(&[]),
2056 &["gone.ts".to_string()],
2057 );
2058 let f = findings_of(&v, "gone.ts");
2059 assert_eq!(f.len(), 1, "{v:#?}");
2060 assert!(f[0].detail.contains("modified"), "{v:#?}");
2061 }
2062
2063 const F: &str =
2069 "def helper(x):\n return x + 1\n\n\ndef compute(y):\n return helper(y) * 2\n";
2070 const MAIN_NOUSE: &str = "print(3)\n";
2071 const MAIN_USES: &str = "from f import compute\n\nprint(compute(3))\n";
2072 const G_TOUCHING: &str = "# moved and restructured\n\n\ndef helper(x):\n '''Increment.'''\n return x + 1\n\n\ndef compute(y):\n '''Double of helper.'''\n value = helper(y)\n return value * 2\n";
2075 const G_MOVED: &str = "# moved and restructured\n\n\ndef helper(x):\n return x + 1\n\n\ndef compute(y):\n '''Double of helper.'''\n value = helper(y)\n return value * 2\n";
2077
2078 fn f_edit() -> String {
2079 F.replace("return x + 1", "return x + 2 # fixed off-by-one")
2080 }
2081
2082 fn moddel_check(
2085 main: &str,
2086 theirs_f: &str,
2087 ours_extra: &[(&str, &str)],
2088 work: &[(&str, &str)],
2089 ) -> Vec<Verdict> {
2090 let base = tree(&[("f.py", F), ("main.py", main)]);
2091 let mut o = vec![("main.py", main)];
2092 o.extend_from_slice(ours_extra);
2093 let ours = tree(&o);
2094 let theirs = tree(&[("f.py", theirs_f), ("main.py", main)]);
2095 let mut subjects = vec!["f.py".to_string()];
2096 subjects.extend(ours_extra.iter().map(|(p, _)| p.to_string()));
2097 check(&base, &ours, &theirs, &tree(work), &subjects)
2098 }
2099
2100 fn verdict<'a>(v: &'a [Verdict], file: &str) -> &'a Verdict {
2101 v.iter()
2102 .find(|x| x.file == file)
2103 .expect("a verdict for every subject")
2104 }
2105
2106 #[test]
2107 fn moddel_delete_that_loses_an_edit_is_a_finding() {
2108 let edit = f_edit();
2109 let v = moddel_check(MAIN_NOUSE, &edit, &[], &[("main.py", MAIN_NOUSE)]);
2110 let f = &verdict(&v, "f.py").findings;
2111 assert_eq!(f.len(), 1, "{v:#?}");
2112 assert_eq!(f[0].class, "MODDEL");
2113 assert!(f[0].detail.contains("exists nowhere else"), "{v:#?}");
2114 }
2115
2116 #[test]
2117 fn moddel_delete_of_a_layout_only_edit_is_an_advisory() {
2118 let reflowed = F
2119 .replace("return x + 1", "return x+1")
2120 .replace("\n\n\n", "\n\n\n\n");
2121 let v = moddel_check(MAIN_NOUSE, &reflowed, &[], &[("main.py", MAIN_NOUSE)]);
2122 let f = verdict(&v, "f.py");
2123 assert!(f.ok(), "{v:#?}");
2124 assert_eq!(f.advisories.len(), 1);
2125 assert_eq!(f.advisories[0].class, "MODDEL");
2126 let commented = F.replace("def helper(x):", "def helper(x): # adds one");
2128 let v = moddel_check(MAIN_NOUSE, &commented, &[], &[("main.py", MAIN_NOUSE)]);
2129 assert!(!verdict(&v, "f.py").ok(), "{v:#?}");
2130 }
2131
2132 #[test]
2133 fn moddel_delete_whose_names_are_still_called_is_a_finding() {
2134 let reflowed = F.replace("return x + 1", "return x+1");
2135 let v = moddel_check(MAIN_USES, &reflowed, &[], &[("main.py", MAIN_USES)]);
2136 let f = &verdict(&v, "f.py").findings;
2137 assert_eq!(f.len(), 1, "{v:#?}");
2138 assert_eq!(f[0].class, "MODDEL");
2139 assert!(f[0].detail.contains("`compute`"), "{v:#?}");
2140 }
2141
2142 #[test]
2143 fn moddel_delete_after_a_move_that_carries_the_edit_is_an_advisory() {
2144 let edit = f_edit();
2145 let ported = G_MOVED.replace("return x + 1", "return x + 2 # fixed off-by-one");
2146 let v = moddel_check(
2147 MAIN_NOUSE,
2148 &edit,
2149 &[("g.py", G_MOVED)],
2150 &[("main.py", MAIN_NOUSE), ("g.py", &ported)],
2151 );
2152 let f = verdict(&v, "f.py");
2153 assert!(f.ok(), "{v:#?}");
2154 assert!(f.advisories[0].detail.contains("`g.py`"), "{v:#?}");
2155 }
2156
2157 #[test]
2158 fn moddel_delete_after_a_move_that_dropped_the_edit_offers_the_port() {
2159 let edit = f_edit();
2160 let v = moddel_check(
2161 MAIN_NOUSE,
2162 &edit,
2163 &[("g.py", G_MOVED)],
2164 &[("main.py", MAIN_NOUSE), ("g.py", G_MOVED)],
2165 );
2166 let f = &verdict(&v, "f.py").findings;
2167 assert_eq!(f.len(), 1, "{v:#?}");
2168 assert_eq!(f[0].class, "MODDEL");
2169 let s = f[0].suggestion.as_deref().unwrap();
2170 assert!(s.contains("--- a/g.py"), "{s}");
2171 assert!(s.contains("+ return x + 2 # fixed off-by-one"), "{s}");
2172 }
2173
2174 #[test]
2175 fn moddel_delete_after_a_move_the_edit_cannot_follow_is_a_finding() {
2176 let edit = f_edit();
2177 let v = moddel_check(
2178 MAIN_NOUSE,
2179 &edit,
2180 &[("g.py", G_TOUCHING)],
2181 &[("main.py", MAIN_NOUSE), ("g.py", G_TOUCHING)],
2182 );
2183 let f = &verdict(&v, "f.py").findings;
2184 assert_eq!(f.len(), 1, "{v:#?}");
2185 assert!(f[0].detail.contains("does not re-apply"), "{v:#?}");
2186 }
2187
2188 #[test]
2189 fn moddel_keep_after_a_move_duplicates_the_definitions() {
2190 let edit = f_edit();
2191 let v = moddel_check(
2192 MAIN_NOUSE,
2193 &edit,
2194 &[("g.py", G_MOVED)],
2195 &[("main.py", MAIN_NOUSE), ("g.py", G_MOVED), ("f.py", &edit)],
2196 );
2197 let f = &verdict(&v, "f.py").findings;
2198 assert_eq!(f.len(), 1, "{v:#?}");
2199 assert_eq!(f[0].class, "MODDEL");
2200 assert!(f[0].detail.contains("`compute`"), "{v:#?}");
2201 }
2202
2203 #[test]
2204 fn moddel_keep_without_a_move_is_an_advisory() {
2205 let edit = f_edit();
2206 let v = moddel_check(
2207 MAIN_NOUSE,
2208 &edit,
2209 &[],
2210 &[("main.py", MAIN_NOUSE), ("f.py", &edit)],
2211 );
2212 let f = verdict(&v, "f.py");
2213 assert!(f.ok(), "{v:#?}");
2214 assert_eq!(f.advisories.len(), 1);
2215 assert_eq!(f.advisories[0].class, "MODDEL");
2216 assert!(f.line().starts_with("OK: f.py"), "{v:#?}");
2217 }
2218
2219 #[test]
2220 fn a_file_git_relocated_into_a_renamed_directory_is_checked_against_its_adder() {
2221 let job =
2224 "@staticmethod\ndef a():\n return 1\n\n@staticmethod\ndef b():\n return 2\n";
2225 let v = check(
2226 &tree(&[]),
2227 &tree(&[]),
2228 &tree(&[("old/job.py", job)]),
2229 &tree(&[("new/job.py", job)]),
2230 &["new/job.py".to_string(), "old/job.py".to_string()],
2231 );
2232 assert!(v.iter().all(Verdict::ok), "{v:#?}");
2233 assert!(
2234 verdict(&v, "old/job.py").line().contains("`new/job.py`"),
2235 "{v:#?}"
2236 );
2237 }
2238
2239 #[test]
2240 fn a_local_const_arrow_resolves_the_name() {
2241 let base = tree(&[
2242 ("a.ts", "export function openThing() {\n return 1\n}\n"),
2243 (
2244 "b.ts",
2245 "export function outer() {\n const openThing = (n: number): number => {\n return n\n }\n return openThing(1)\n}\n",
2246 ),
2247 ]);
2248 let ours = tree(&[
2249 ("a.ts", "export function other() {\n return 2\n}\n"),
2250 (
2251 "b.ts",
2252 "export function outer() {\n const openThing = (n: number): number => {\n return n\n }\n return openThing(1)\n}\n",
2253 ),
2254 ]);
2255 let theirs = tree(&[
2256 ("a.ts", "export function openThing() {\n return 1\n}\n"),
2257 (
2258 "b.ts",
2259 "export function outer() {\n const openThing = (n: number): number => {\n return n + 1\n }\n return openThing(1)\n}\n",
2260 ),
2261 ]);
2262 let work = tree(&[
2263 ("a.ts", "export function other() {\n return 2\n}\n"),
2264 (
2265 "b.ts",
2266 "export function outer() {\n const openThing = (n: number): number => {\n return n + 1\n }\n return openThing(1)\n}\n",
2267 ),
2268 ]);
2269 let v = check(
2270 &base,
2271 &ours,
2272 &theirs,
2273 &work,
2274 &["a.ts".to_string(), "b.ts".to_string()],
2275 );
2276 assert!(findings_of(&v, "b.ts").is_empty(), "{v:#?}");
2277 }
2278
2279 #[test]
2280 fn prose_is_not_checked_for_references() {
2281 let base = tree(&[
2282 (
2283 "util.ts",
2284 "export function clamp(n: number) {\n return n\n}\n",
2285 ),
2286 ("notes.md", "# Notes\n\nWe clamp (roughly) the total.\n"),
2287 ]);
2288 let ours = tree(&[
2289 (
2290 "util.ts",
2291 "export function floor(n: number) {\n return n\n}\n",
2292 ),
2293 ("notes.md", "# Notes\n\nWe clamp (roughly) the total.\n"),
2294 ]);
2295 let theirs = tree(&[
2296 (
2297 "util.ts",
2298 "export function clamp(n: number) {\n return n\n}\n",
2299 ),
2300 (
2301 "notes.md",
2302 "# Notes\n\nWe clamp (roughly) the total, always.\n",
2303 ),
2304 ]);
2305 let work = tree(&[
2306 (
2307 "util.ts",
2308 "export function floor(n: number) {\n return n\n}\n",
2309 ),
2310 (
2311 "notes.md",
2312 "# Notes\n\nWe clamp (roughly) the total, always.\n",
2313 ),
2314 ]);
2315 let v = check(
2316 &base,
2317 &ours,
2318 &theirs,
2319 &work,
2320 &["notes.md".to_string(), "util.ts".to_string()],
2321 );
2322 assert!(findings_of(&v, "notes.md").is_empty(), "{v:#?}");
2323 }
2324
2325 #[test]
2326 fn line_counts_follow_a_rename() {
2327 let base_text =
2328 "export function* widgetFlow() {\n yield* widget.open(gadget.name)\n return 0\n}\n";
2329 let ours_text = "export function* widgetFlow() {\n yield* widget.open(gadget.name)\n yield* widget.open(gadget.name)\n yield* widget.open(gadget.name)\n return 0\n}\n";
2330 let v = check(
2331 &tree(&[("old.ts", base_text)]),
2332 &tree(&[("old.ts", ours_text)]),
2333 &tree(&[("new.ts", base_text)]),
2334 &tree(&[("new.ts", ours_text)]),
2335 &["new.ts".to_string(), "old.ts".to_string()],
2336 );
2337 assert!(findings_of(&v, "new.ts").is_empty(), "{v:#?}");
2338 assert!(findings_of(&v, "old.ts").is_empty(), "{v:#?}");
2339 }
2340
2341 #[test]
2342 fn a_report_with_no_files_says_so_in_a_sentence_and_never_prints_nothing() {
2343 let r = Report {
2344 scope: "no merge in progress".to_string(),
2345 verdicts: Vec::new(),
2346 };
2347 let text = r.render();
2348 assert!(text.contains("NOTHING WAS VERIFIED"), "{text}");
2349 assert!(!text.trim().is_empty());
2350 assert!(!text.trim().starts_with('['));
2351 }
2352}